feat: harden auth storage and telegram workflows

- add first-run admin setup with HttpOnly cookie sessions
- scope file operations to the active storage account
- improve Telegram auth, downloads, and background tasks
- harden upload, signed URL, CORS, and local path handling
- update docs, logo, lint configuration, and frontend chunks
This commit is contained in:
hicocos committed 2026-07-02 15:17:30 +08:00
1 parent fd2faa97e7
commit 71d2de6dbe
38 files changed
+4452 -2037

No files matched your search

+33 -9
View File
@@ -43,6 +43,9 @@ vi .env
- 如需 Telegram Bot,再填写 `TELEGRAM_BOT_TOKEN`、`TELEGRAM_API_ID`、`TELEGRAM_API_HASH`
- 如需账号级 Telegram 下载器,再填写 `TELEGRAM_USER_API_ID`、`TELEGRAM_USER_API_HASH`
> [!TIP]
> `ACCESS_PASSWORD_HASH` 现在仅作为旧部署兼容项。全新部署推荐留空,启动后首次访问 Web 页面创建网页管理员密码和 Telegram Bot 4 位 PIN。
### 3. 构建前端
`VITE_API_URL` 是前端构建时变量。请先在 `.env` 中设置好它,然后用变量传入构建命令:
@@ -99,7 +102,7 @@ docker compose up -d
| `DB_PASSWORD` | PostgreSQL 数据库密码 | `change_me_to_a_strong_password` | 自行生成强密码,首次部署前写入 `.env` |
| `CORS_ORIGIN` | 允许跨域的前端来源 | `https://cloud.yourdomain.com` | 你的前端网页公网地址,例如 Nginx/Caddy 指向宿主机 `47832` |
| `DOMAIN` | 应用主域名,不带协议 | `cloud.yourdomain.com` | 填前端主域名,用于生成链接和展示 |
| `ACCESS_PASSWORD_HASH` | 可选,网页登录/接口访问密码的 SHA-256 Hash | `sha256_hash_here...` | 见“生成密码哈希”章节;不填则不启用访问密码 |
| `ACCESS_PASSWORD_HASH` | 可选,旧部署兼容的网页登录密码 SHA-256 Hash | `sha256_hash_here...` | 新部署推荐留空并通过首次 Web 初始化创建管理员;仅迁移旧配置时使用 |
| `TELEGRAM_BOT_TOKEN` | 可选,Telegram Bot Token | `123456:ABC-DEF...` | 找 [@BotFather](https://t.me/BotFather) 创建机器人后获取 |
| `TELEGRAM_API_ID` | 可选,Telegram API ID | `123456` | 登录 [my.telegram.org](https://my.telegram.org) 创建应用后获取 |
| `TELEGRAM_API_HASH` | 可选,Telegram API Hash | `abcdef123456...` | 与 `TELEGRAM_API_ID` 在同一页面获取 |
@@ -216,12 +219,23 @@ docker compose up -d
## 🔐 安全与访问控制
如果设置了 `ACCESS_PASSWORD_HASH`,访问网页和 API 将需要输入密码。本应用目前使用 SHA-256 算法进行哈希。
FlClouds 默认采用“首次初始化”模式保护 Web 和 API:
> [!CAUTION]
> Telegram Bot 键盘只适合四位数字密码输入场景;如果你通过 Bot 使用密码登录,请设置四位数字并生成对应 SHA-256 Hash。
1. 全新部署时可以不填写 `ACCESS_PASSWORD_HASH`。
2. 服务启动后,首次访问 Web 页面会要求创建:
- 网页管理员密码:至少 8 位,使用 `scrypt` 加盐哈希后保存到数据库。
- Telegram Bot 4 位 PIN:仅用于 Bot `/start` 身份验证,同样使用 `scrypt` 加盐哈希保存。
3. 登录成功后,浏览器会获得 HttpOnly Cookie 会话,前端不再把访问 token 写入 `localStorage`。
4. 修改类请求会校验 `Origin`,请确保 `.env` 中的 `CORS_ORIGIN` 与前端公网地址一致。
### 生成密码哈希
> [!IMPORTANT]
> 生产环境请使用 HTTPS。默认 `COOKIE_SECURE=true` 时,浏览器只会在 HTTPS 下发送登录 Cookie;如果你只在本地 HTTP 调试,可临时设置 `COOKIE_SECURE=false`。
### 旧部署密码兼容
旧版本通过 `ACCESS_PASSWORD_HASH` 配置 SHA-256 密码哈希。当前版本仍兼容这种方式,便于平滑升级;但新部署推荐使用首次初始化创建管理员密码。
如果必须生成旧式 SHA-256 Hash,可使用:
```bash
node -e "console.log(require('crypto').createHash('sha256').update('your_password').digest('hex'))"
@@ -233,11 +247,11 @@ Linux/macOS 也可以:
echo -n "your_password" | sha256sum | awk '{print $1}'
```
将生成的 64 位字符串填入 `.env` 的 `ACCESS_PASSWORD_HASH`。
将生成的 64 位字符串填入 `.env` 的 `ACCESS_PASSWORD_HASH`。使用旧式哈希时,Telegram Bot 会在首次设置独立 PIN 前兼容旧密码验证。
### 自动密钥说明
FlClouds 会在首次启动时自动生成内部密钥,并保存到 Docker 数据卷的 `/data/secrets/` 目录中。正常部署无需手动配置。迁移服务器时请连同 Docker volume 一起备份,否则已加密的第三方存储凭证可能需要重新添加。
FlClouds 会在首次启动时自动生成内部密钥,并保存到 Docker 数据卷的 `/data/secrets/` 目录中。正常部署无需手动配置。迁移服务器时请连同 Docker volume 一起备份,否则登录会话、TOTP 密钥和已加密的第三方存储凭证可能需要重新配置。
### 双重验证 (TOTP)
@@ -258,8 +272,16 @@ FlClouds 内置支持 TOTP 双重验证(如 Google Authenticator):
| `cloud.example.com` | HTTPS | `127.0.0.1:47832` | 前端/网页入口 |
| `api.example.com` | HTTPS | `127.0.0.1:51947` | 后端/API 接口 |
如果前后端使用不同域名,请在后端环境变量中设置:
```env
VITE_API_URL=https://api.example.com
CORS_ORIGIN=https://cloud.example.com
COOKIE_SECURE=true
```
> [!CAUTION]
> 开启 HTTPS 后,`.env` 中的 `VITE_API_URL` 和 `CORS_ORIGIN` 都应使用 `https://`,否则浏览器可能拦截请求。
> 开启 HTTPS 后,`.env` 中的 `VITE_API_URL` 和 `CORS_ORIGIN` 都应使用 `https://`,否则浏览器可能拦截请求。修改 `VITE_API_URL` 后必须重新构建前端镜像,因为它会被打包进静态文件。
---
@@ -316,7 +338,9 @@ docker system prune -f
- ⚙️ Telegram 并发下载 worker 调参,激进模式带二次确认
- 🧯 重复文件处理、路径规则和本地孤儿文件清理开关
- 📥 yt-dlp 视频链接下载到当前存储源
- 🔐 Web / Bot 双重验证与访问密码保护
- 🔐 首次 Web 初始化管理员密码,HttpOnly Cookie 会话与 Bot 独立 PIN
- 🛡️ Origin 校验、签名 URL、存储账户作用域隔离与本地路径安全保护
- ⚡ 前端按需加载与依赖拆包,降低首屏 JS 体积
- 🧩 Google Drive 等存储源配置与授权刷新
- 🐳 Docker Compose 容器化部署
+2150 -1173
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS files (
height INT,
source VARCHAR(50) DEFAULT 'web',
folder VARCHAR(255),
storage_account_id UUID REFERENCES storage_accounts(id),
storage_account_id UUID REFERENCES storage_accounts(id) ON DELETE CASCADE,
is_favorite BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
@@ -92,6 +92,7 @@ CREATE TABLE IF NOT EXISTS telegram_channel_subscriptions (
source TEXT NOT NULL,
title TEXT,
last_message_id INT DEFAULT 0,
folder_override TEXT,
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
@@ -100,6 +101,7 @@ CREATE TABLE IF NOT EXISTS telegram_channel_subscriptions (
CREATE INDEX IF NOT EXISTS idx_tg_channel_subscriptions_enabled ON telegram_channel_subscriptions(enabled);
CREATE INDEX IF NOT EXISTS idx_tg_channel_subscriptions_user_id ON telegram_channel_subscriptions(user_id);
ALTER TABLE telegram_channel_subscriptions ADD COLUMN IF NOT EXISTS folder_override TEXT;
CREATE OR REPLACE TRIGGER telegram_channel_subscriptions_updated_at
BEFORE UPDATE ON telegram_channel_subscriptions
+14 -2
View File
@@ -55,15 +55,27 @@ app.use(cors({
if (!origin || allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error(`CORS origin not allowed: ${origin}`));
return callback(null, false);
},
credentials: true,
credentials: !allowAnyOrigin,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'X-API-Key', 'X-Upload-Id', 'X-Chunk-Index', 'Authorization'],
}));
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '2mb' }));
// Browser-side CSRF/origin guard for state-changing requests. Non-browser API clients
// often omit Origin; those requests still require normal authentication/API keys.
app.use((req, res, next) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const origin = req.headers.origin;
if (!origin) return next();
if (!allowAnyOrigin && !allowedOrigins.includes(origin)) {
return res.status(403).json({ error: 'Origin not allowed' });
}
next();
});
// 安全头部
app.use(helmet({
contentSecurityPolicy: {
+72 -46
View File
@@ -1,12 +1,13 @@
import { Router, Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import { ACCESS_PASSWORD_HASH, SESSION_SECRET, TOKEN_EXPIRY } from '../utils/config.js';
import { TOKEN_EXPIRY } from '../utils/config.js';
import { generateSignature, type SignedUrlType } from '../middleware/signedUrl.js';
import { rateLimit } from 'express-rate-limit';
import { is2FAEnabled, verifyTOTP, generateOTPAuthUrl, activate2FA, disable2FA, getClientIP } from '../utils/security.js';
import { UAParser } from 'ua-parser-js';
import axios from 'axios';
import { sendSecurityNotification } from '../services/telegramBot.js';
import { createInitialAdminCredentials, isInitialSetupRequired, validateTelegramPin, validateWebPassword, verifyWebPassword } from '../utils/authSettings.js';
// 导入可能需要的辅助函数
async function getIPLocation(ip: string) {
@@ -45,6 +46,11 @@ async function sendLoginNotification(req: Request) {
const router = Router();
const SIGNED_URL_TYPES = new Set<SignedUrlType>(['preview', 'thumbnail', 'download']);
const MAX_SIGNED_URL_EXPIRES_IN_SECONDS: Record<SignedUrlType, number> = {
thumbnail: 24 * 60 * 60,
preview: 60 * 60,
download: 60 * 60,
};
function normalizeSignedUrlType(value: unknown): SignedUrlType | null {
if (typeof value !== 'string') return null;
@@ -63,7 +69,7 @@ function setAuthCookie(res: Response, token: string, expiresAt: Date) {
res.cookie('flclouds_token', token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
secure: process.env.COOKIE_SECURE === 'true' || process.env.NODE_ENV === 'production',
expires: expiresAt,
path: '/',
});
@@ -87,7 +93,7 @@ setInterval(() => {
});
}, 60 * 60 * 1000); // 每小时清理一次
// 生成密码哈希(用于配置)
// 生成密码哈希(兼容旧部署文档)
export function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
}
@@ -97,20 +103,18 @@ function generateToken(): string {
return crypto.randomBytes(32).toString('hex');
}
// 验证密码
function verifyPassword(password: string): boolean {
if (!ACCESS_PASSWORD_HASH) {
// 如果没有设置密码,允许访问
return true;
}
const inputHash = hashPassword(password);
try {
const input = Buffer.from(inputHash, 'hex');
const expected = Buffer.from(ACCESS_PASSWORD_HASH, 'hex');
return input.length === expected.length && crypto.timingSafeEqual(input, expected);
} catch {
return false;
}
function issueSession(req: Request, res: Response) {
const token = generateToken();
const now = new Date();
const expiresAt = new Date(now.getTime() + TOKEN_EXPIRY);
sessions.set(token, { createdAt: now, expiresAt });
sendLoginNotification(req).catch(error => console.warn('发送登录通知失败:', error));
setAuthCookie(res, token, expiresAt);
res.json({
success: true,
expiresAt: expiresAt.toISOString(),
});
}
// 登录频率限制:15分钟内最多5次尝试
@@ -123,15 +127,39 @@ const loginLimiter = rateLimit({
keyGenerator: (req: Request) => getClientIP(req),
});
router.post('/setup', loginLimiter, async (req: Request, res: Response) => {
try {
if (!(await isInitialSetupRequired())) {
return res.status(409).json({ error: '管理员密码已创建' });
}
const { webPassword, telegramPin } = req.body;
const webError = validateWebPassword(webPassword);
if (webError) return res.status(400).json({ error: webError });
const pinError = validateTelegramPin(telegramPin);
if (pinError) return res.status(400).json({ error: pinError });
await createInitialAdminCredentials(webPassword, telegramPin);
issueSession(req, res);
} catch (error) {
console.error('初始化管理员失败:', error);
res.status(500).json({ error: error instanceof Error ? error.message : '初始化管理员失败' });
}
});
// 登录接口
router.post('/login', loginLimiter, async (req: Request, res: Response) => {
const { password } = req.body;
if (await isInitialSetupRequired()) {
return res.status(428).json({ error: '请先创建管理员密码', setupRequired: true });
}
if (!password) {
return res.status(400).json({ error: '请输入密码' });
}
if (!verifyPassword(password)) {
if (!(await verifyWebPassword(password))) {
return res.status(401).json({ error: '认证失败' });
}
@@ -140,26 +168,11 @@ router.post('/login', loginLimiter, async (req: Request, res: Response) => {
return res.json({
success: true,
requiresTOTP: true,
// 暂时不生成完整 token,只在 TOTP 验证后返回
message: '请输入二次验证码'
});
}
const token = generateToken();
const now = new Date();
const expiresAt = new Date(now.getTime() + TOKEN_EXPIRY);
sessions.set(token, { createdAt: now, expiresAt });
// 异步发送通知
sendLoginNotification(req);
setAuthCookie(res, token, expiresAt);
res.json({
success: true,
token,
expiresAt: expiresAt.toISOString(),
});
issueSession(req, res);
});
// TOTP 验证接口
@@ -171,7 +184,7 @@ router.post('/verify-totp', loginLimiter, async (req: Request, res: Response) =>
}
// 再次验证密码(确保安全性)
if (!verifyPassword(password)) {
if (!(await verifyWebPassword(password))) {
return res.status(401).json({ error: '认证失败' });
}
@@ -192,7 +205,6 @@ router.post('/verify-totp', loginLimiter, async (req: Request, res: Response) =>
setAuthCookie(res, token, expiresAt);
res.json({
success: true,
token,
expiresAt: expiresAt.toISOString(),
});
});
@@ -231,7 +243,7 @@ router.post('/2fa-disable', requireAuth, async (req: Request, res: Response) =>
const { password } = req.body;
if (!password) return res.status(400).json({ error: '请输入密码验证' });
if (!verifyPassword(password)) {
if (!(await verifyWebPassword(password))) {
return res.status(401).json({ error: '认证失败' });
}
@@ -245,7 +257,10 @@ router.post('/2fa-disable', requireAuth, async (req: Request, res: Response) =>
});
// 验证 Token
router.get('/verify', (req: Request, res: Response) => {
router.get('/verify', async (req: Request, res: Response) => {
if (await isInitialSetupRequired()) {
return res.status(428).json({ valid: false, setupRequired: true, error: '请先创建管理员密码' });
}
const token = getAuthToken(req);
if (!token) {
@@ -271,10 +286,12 @@ router.post('/logout', (req: Request, res: Response) => {
res.json({ success: true });
});
// 检查是否需要密码
router.get('/status', (_req: Request, res: Response) => {
// 检查认证初始化状态
router.get('/status', async (_req: Request, res: Response) => {
const setupRequired = await isInitialSetupRequired();
res.json({
passwordRequired: !!ACCESS_PASSWORD_HASH,
setupRequired,
passwordRequired: true,
});
});
@@ -291,11 +308,21 @@ router.post('/sign-url', requireAuth, (req: Request, res: Response) => {
return res.status(400).json({ error: '签名类型无效' });
}
const expiresInSeconds = Number(expiresIn);
const expiresInSeconds = Math.floor(Number(expiresIn));
if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) {
return res.status(400).json({ error: '过期时间无效' });
}
const maxExpiresIn = MAX_SIGNED_URL_EXPIRES_IN_SECONDS[signedType];
if (expiresInSeconds > maxExpiresIn) {
return res.status(400).json({
error: signedType === 'thumbnail'
? '缩略图签名有效期不能超过 24 小时'
: '预览/下载签名有效期不能超过 1 小时',
maxExpiresIn,
});
}
const expires = Date.now() + (expiresInSeconds * 1000);
const sign = generateSignature(fileId, signedType, expires);
@@ -308,10 +335,9 @@ router.post('/sign-url', requireAuth, (req: Request, res: Response) => {
});
// 认证中间件
export function requireAuth(req: Request, res: Response, next: NextFunction) {
// 如果没有设置密码,跳过认证
if (!ACCESS_PASSWORD_HASH) {
return next();
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
if (await isInitialSetupRequired()) {
return res.status(428).json({ error: '请先创建管理员密码', setupRequired: true });
}
// 优先从 Authorization header 获取 token
+41 -11
View File
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import path from 'path';
import fs from 'fs';
import { pipeline } from 'stream/promises';
import { query } from '../db/index.js';
import { generateThumbnail, getImageDimensions } from '../utils/thumbnail.js';
import { storageManager } from '../services/storage.js';
@@ -248,17 +249,45 @@ router.post('/complete', async (req: Request, res: Response) => {
console.log(`[ChunkedComplete] 🧩 Merging ${session.totalChunks} chunks for: ${session.filename}`);
console.log(`[ChunkedComplete] 🏠 Final temp path: ${tempMergedPath}`);
for (let i = 0; i < session.totalChunks; i++) {
const chunkPath = path.join(CHUNK_DIR, uploadId, `chunk_${i}`);
const chunkData = fs.readFileSync(chunkPath);
writeStream.write(chunkData);
try {
for (let i = 0; i < session.totalChunks; i++) {
const chunkPath = path.join(CHUNK_DIR, uploadId, `chunk_${i}`);
if (!fs.existsSync(chunkPath)) {
throw new Error(`缺少分块 ${i}`);
}
const chunkStat = fs.statSync(chunkPath);
if (chunkStat.size <= 0 || chunkStat.size > MAX_CHUNK_BYTES) {
throw new Error(`分块 ${i} 大小无效`);
}
await pipeline(fs.createReadStream(chunkPath), writeStream, { end: false });
}
await new Promise<void>((resolve, reject) => {
writeStream.end();
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
} catch (mergeError) {
writeStream.destroy();
if (fs.existsSync(tempMergedPath)) fs.unlinkSync(tempMergedPath);
const chunkDir = path.join(CHUNK_DIR, uploadId);
if (fs.existsSync(chunkDir)) fs.rmSync(chunkDir, { recursive: true });
uploadSessions.delete(uploadId);
return res.status(400).json({ error: mergeError instanceof Error ? mergeError.message : '合并分块失败' });
}
await new Promise<void>((resolve, reject) => {
writeStream.end();
writeStream.on('finish', resolve);
writeStream.on('error', reject);
});
const mergedStat = fs.statSync(tempMergedPath);
if (mergedStat.size !== session.totalSize) {
if (fs.existsSync(tempMergedPath)) fs.unlinkSync(tempMergedPath);
const chunkDir = path.join(CHUNK_DIR, uploadId);
if (fs.existsSync(chunkDir)) fs.rmSync(chunkDir, { recursive: true });
uploadSessions.delete(uploadId);
return res.status(400).json({
error: '合并后文件大小与声明大小不一致',
expectedSize: session.totalSize,
actualSize: mergedStat.size,
});
}
// 清理分块
const chunkDir = path.join(CHUNK_DIR, uploadId);
@@ -286,11 +315,13 @@ router.post('/complete', async (req: Request, res: Response) => {
}
// 5. 在保存到永久存储前生成缩略图和获取尺寸
// 方案A:只在本地存储生成缩略图;第三方存储不生成本地缩略图。
let thumbnailPath = null;
let width = null;
let height = null;
const provider = storageManager.getProvider();
if (session.mimeType.startsWith('image/') || session.mimeType.startsWith('video/')) {
if (provider.name === 'local' && (session.mimeType.startsWith('image/') || session.mimeType.startsWith('video/'))) {
try {
console.log(`[ChunkedComplete] 🖼️ MIME: ${session.mimeType}, starting generation...`);
const thumbResult = await generateThumbnail(tempMergedPath, storedName, session.mimeType);
@@ -310,7 +341,6 @@ router.post('/complete', async (req: Request, res: Response) => {
// 6. 保存到永久存储
let storedPath = '';
const provider = storageManager.getProvider();
console.log(`[ChunkedComplete] 🛠️ Provider: ${provider.name}, accountId: ${activeAccountId || 'none (local)'}`);
try {
storedPath = await provider.saveFile(tempMergedPath, storedName, session.mimeType, storageFolder);
+85 -213
View File
@@ -2,14 +2,53 @@ import { Router, Request, Response } from 'express';
import { query } from '../db/index.js';
import fs from 'fs';
import path from 'path';
import { generateSignature, getSignedUrl } from '../middleware/signedUrl.js';
import { safeUnlink } from '../utils/localPath.js';
import { getSignedUrl } from '../middleware/signedUrl.js';
import { getScopedFileById, removePhysicalFile, updateScopedFileById } from '../utils/fileScope.js';
import { isPathInside } from '../utils/localPath.js';
const router = Router();
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || './data/uploads');
const THUMBNAIL_DIR = path.resolve(process.env.THUMBNAIL_DIR || './data/thumbnails');
async function getSafeLocalFilePath(file: any): Promise<string> {
const candidate = file.path || path.join(UPLOAD_DIR, file.stored_name);
const resolved = path.resolve(candidate);
if (!isPathInside(UPLOAD_DIR, resolved)) {
throw new Error('Unsafe local file path');
}
if (!fs.existsSync(resolved)) {
return resolved;
}
const real = await fs.promises.realpath(resolved);
if (!isPathInside(UPLOAD_DIR, real)) {
throw new Error('Unsafe local file path');
}
return real;
}
function parseRangeHeader(range: string | undefined, size: number): { start: number; end: number } | null {
if (!range) return null;
const match = range.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
let start: number;
let end: number;
if (match[1] === '' && match[2] === '') return null;
if (match[1] === '') {
const suffixLength = Number(match[2]);
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return null;
start = Math.max(size - suffixLength, 0);
end = size - 1;
} else {
start = Number(match[1]);
end = match[2] === '' ? size - 1 : Number(match[2]);
}
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) {
return null;
}
return { start, end: Math.min(end, size - 1) };
}
// 获取文件列表
router.get('/', async (req: Request, res: Response) => {
try {
@@ -168,13 +207,11 @@ router.post('/folders/favorite', async (req: Request, res: Response) => {
router.get('/:id([0-9a-fA-F-]{36})', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const result = await query('SELECT * FROM files WHERE id = $1', [id]);
const file = await getScopedFileById(id);
if (result.rows.length === 0) {
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const file = result.rows[0];
res.json({
...file,
size: formatFileSize(file.size),
@@ -194,14 +231,12 @@ router.get('/:id([0-9a-fA-F-]{36})', async (req: Request, res: Response) => {
router.get('/:id([0-9a-fA-F-]{36})/preview', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const result = await query('SELECT * FROM files WHERE id = $1', [id]);
const file = await getScopedFileById(id);
if (result.rows.length === 0) {
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const file = result.rows[0];
if (file.source === 'onedrive' || file.source === 'aliyun_oss' || file.source === 's3' || file.source === 'webdav' || file.source === 'google_drive') {
try {
const { storageManager } = await import('../services/storage.js');
@@ -228,7 +263,7 @@ router.get('/:id([0-9a-fA-F-]{36})/preview', async (req: Request, res: Response)
}
// 处理本地文件 (source === 'web' 或 'local')
const filePath = file.path || path.join(UPLOAD_DIR, file.stored_name);
const filePath = await getSafeLocalFilePath(file);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: '文件不存在于服务器' });
@@ -246,9 +281,16 @@ router.get('/:id([0-9a-fA-F-]{36})/preview', async (req: Request, res: Response)
const range = req.headers.range;
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
const parsedRange = parseRangeHeader(range, stat.size);
if (!parsedRange) {
res.status(416);
res.set({
'Content-Range': `bytes */${stat.size}`,
'Accept-Ranges': 'bytes',
});
return res.end();
}
const { start, end } = parsedRange;
const chunksize = end - start + 1;
res.status(206);
@@ -275,14 +317,12 @@ router.get('/:id([0-9a-fA-F-]{36})/preview', async (req: Request, res: Response)
router.get('/:id([0-9a-fA-F-]{36})/download-url', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const result = await query('SELECT * FROM files WHERE id = $1', [id]);
const file = await getScopedFileById(id);
if (result.rows.length === 0) {
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const file = result.rows[0];
// 1. 云存储文件:获取临时下载链接
if (file.source === 'onedrive' || file.source === 'aliyun_oss' || file.source === 's3' || file.source === 'webdav' || file.source === 'google_drive') {
try {
@@ -321,15 +361,12 @@ router.get('/:id([0-9a-fA-F-]{36})/download', async (req: Request, res: Response
const { id } = req.params;
console.log(`[Download] Starting download for ID: ${id}`);
const result = await query('SELECT * FROM files WHERE id = $1', [id]);
const file = await getScopedFileById(id);
if (result.rows.length === 0) {
console.log(`[Download] File not found in DB: ${id}`);
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const file = result.rows[0];
// 处理云存储文件 (如果直接访问此接口,仍然尝试重定向或流式传输)
if (file.source === 'onedrive' || file.source === 'aliyun_oss' || file.source === 's3' || file.source === 'webdav' || file.source === 'google_drive') {
try {
@@ -353,7 +390,7 @@ router.get('/:id([0-9a-fA-F-]{36})/download', async (req: Request, res: Response
}
// 使用数据库中存储的完整路径(支持文件夹内的文件)
const filePath = file.path || path.join(UPLOAD_DIR, file.stored_name);
const filePath = await getSafeLocalFilePath(file);
console.log(`[Download] Serving local file: ${filePath}`);
if (!fs.existsSync(filePath)) {
@@ -377,14 +414,12 @@ router.get('/:id([0-9a-fA-F-]{36})/download', async (req: Request, res: Response
router.get('/:id([0-9a-fA-F-]{36})/thumbnail', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const result = await query('SELECT * FROM files WHERE id = $1', [id]);
const file = await getScopedFileById(id);
if (result.rows.length === 0) {
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const file = result.rows[0];
if (!file.thumbnail_path) {
// 如果是 OneDrive 且没有本地缩略图,可以尝试重定向到 OneDrive 的缩略图(如果有 API)
// 目前暂不支持,返回 404 或默认图
@@ -414,32 +449,16 @@ router.get('/:id([0-9a-fA-F-]{36})/thumbnail', async (req: Request, res: Respons
router.delete('/:id([0-9a-fA-F-]{36})', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const result = await query('SELECT * FROM files WHERE id = $1', [id]);
const file = await getScopedFileById(id);
if (result.rows.length === 0) {
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const file = result.rows[0];
if (file.source === 'onedrive' || file.source === 'aliyun_oss' || file.source === 's3' || file.source === 'webdav' || file.source === 'google_drive') {
try {
const { storageManager } = await import('../services/storage.js');
const provider = storageManager.getProvider(`${file.source}:${file.storage_account_id}`);
await provider.deleteFile(file.path);
} catch (err) {
console.error(`${file.source} 文件删除失败 (可能已不存在):`, err);
}
} else {
// 删除实际文件(使用数据库中存储的完整路径)
const filePath = file.path || path.join(UPLOAD_DIR, file.stored_name);
await safeUnlink(filePath, UPLOAD_DIR);
}
// 删除缩略图 (本地)
if (file.thumbnail_path) {
const thumbPath = path.join(THUMBNAIL_DIR, path.basename(file.thumbnail_path));
await safeUnlink(thumbPath, THUMBNAIL_DIR);
try {
await removePhysicalFile(file);
} catch (err) {
console.error(`物理文件删除失败 (可能已不存在):`, err);
}
// 删除数据库记录
@@ -452,74 +471,6 @@ router.delete('/:id([0-9a-fA-F-]{36})', async (req: Request, res: Response) => {
}
});
// 批量删除文件和文件夹
router.post('/batch-delete', async (req: Request, res: Response) => {
try {
const { fileIds = [], folderNames = [] } = req.body;
if (!Array.isArray(fileIds) || !Array.isArray(folderNames)) {
return res.status(400).json({ error: '参数格式错误' });
}
if (fileIds.length === 0 && folderNames.length === 0) {
return res.status(400).json({ error: '请提供要删除的文件或文件夹' });
}
// 1. 获取所有待删除的文件记录
let filesToDelete: any[] = [];
if (fileIds.length > 0) {
const result = await query('SELECT * FROM files WHERE id = ANY($1)', [fileIds]);
filesToDelete = [...filesToDelete, ...result.rows];
}
if (folderNames.length > 0) {
const result = await query('SELECT * FROM files WHERE folder = ANY($1)', [folderNames]);
filesToDelete = [...filesToDelete, ...result.rows];
}
// 去重
const uniqueFiles = Array.from(new Map(filesToDelete.map(f => [f.id, f])).values());
if (uniqueFiles.length === 0) {
return res.json({ success: true, message: '没有发现待删除的项目' });
}
// 2. 依次物理删除
const storagePromises = uniqueFiles.map(async (file) => {
try {
if (file.source === 'onedrive' || file.source === 'aliyun_oss' || file.source === 's3' || file.source === 'webdav' || file.source === 'google_drive') {
const { storageManager } = await import('../services/storage.js');
const provider = storageManager.getProvider(`${file.source}:${file.storage_account_id}`);
await provider.deleteFile(file.path);
} else {
const filePath = file.path || path.join(UPLOAD_DIR, file.stored_name);
await safeUnlink(filePath, UPLOAD_DIR);
}
// 删除缩略图
if (file.thumbnail_path) {
const thumbPath = path.join(THUMBNAIL_DIR, path.basename(file.thumbnail_path));
await safeUnlink(thumbPath, THUMBNAIL_DIR);
}
} catch (err) {
console.error(`删除物理文件失败 (ID: ${file.id}):`, err);
}
});
await Promise.all(storagePromises);
// 3. 从数据库批量删除
const idsToDelete = uniqueFiles.map(f => f.id);
await query('DELETE FROM files WHERE id = ANY($1)', [idsToDelete]);
res.json({ success: true, message: `成功删除 ${uniqueFiles.length} 个文件` });
} catch (error) {
console.error('批量删除失败:', error);
res.status(500).json({ error: '批量删除失败' });
}
});
// 辅助函数:格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
@@ -562,13 +513,11 @@ router.patch('/:id([0-9a-fA-F-]{36})/rename', async (req: Request, res: Response
return res.status(400).json({ error: '文件名包含非法字符' });
}
const result = await query('SELECT * FROM files WHERE id = $1', [id]);
if (result.rows.length === 0) {
const file = await getScopedFileById(id);
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const file = result.rows[0];
// 检查后缀是否一致
const getExt = (n: string) => {
const dotIndex = n.lastIndexOf('.');
@@ -582,7 +531,7 @@ router.patch('/:id([0-9a-fA-F-]{36})/rename', async (req: Request, res: Response
return res.status(400).json({ error: '不允许修改文件后缀' });
}
await query('UPDATE files SET name = $1 WHERE id = $2', [trimmedName, id]);
await updateScopedFileById(id, 'name = $1', [trimmedName]);
res.json({ success: true, name: trimmedName });
} catch (error) {
@@ -591,47 +540,6 @@ router.patch('/:id([0-9a-fA-F-]{36})/rename', async (req: Request, res: Response
}
});
// 重命名文件夹
router.patch('/rename-folder', async (req: Request, res: Response) => {
try {
const { oldName, newName } = req.body;
if (!oldName || !newName || typeof oldName !== 'string' || typeof newName !== 'string') {
return res.status(400).json({ error: '参数错误' });
}
const trimmedNew = newName.trim();
if (trimmedNew.length === 0) {
return res.status(400).json({ error: '文件夹名不能为空' });
}
if (/[\/\\:*?"<>|]/.test(trimmedNew)) {
return res.status(400).json({ error: '文件夹名包含非法字符' });
}
// 检查旧文件夹是否存在
const checkResult = await query('SELECT COUNT(*) as cnt FROM files WHERE folder = $1', [oldName]);
if (parseInt(checkResult.rows[0].cnt) === 0) {
return res.status(404).json({ error: '文件夹不存在' });
}
// 检查新名称是否已存在
if (trimmedNew !== oldName) {
const existResult = await query('SELECT COUNT(*) as cnt FROM files WHERE folder = $1', [trimmedNew]);
if (parseInt(existResult.rows[0].cnt) > 0) {
return res.status(400).json({ error: '该文件夹名已存在' });
}
}
await query('UPDATE files SET folder = $1 WHERE folder = $2', [trimmedNew, oldName]);
res.json({ success: true, name: trimmedNew });
} catch (error) {
console.error('重命名文件夹失败:', error);
res.status(500).json({ error: '重命名文件夹失败' });
}
});
// 移动文件
router.patch('/:id([0-9a-fA-F-]{36})/move', async (req: Request, res: Response) => {
try {
@@ -648,12 +556,12 @@ router.patch('/:id([0-9a-fA-F-]{36})/move', async (req: Request, res: Response)
return res.status(400).json({ error: '文件夹名包含非法字符' });
}
const result = await query('SELECT * FROM files WHERE id = $1', [id]);
if (result.rows.length === 0) {
const file = await getScopedFileById(id);
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
await query('UPDATE files SET folder = $1, updated_at = NOW() WHERE id = $2', [trimmedFolder, id]);
await updateScopedFileById(id, 'folder = $1, updated_at = NOW()', [trimmedFolder]);
res.json({ success: true, folder: trimmedFolder });
} catch (error) {
@@ -662,53 +570,18 @@ router.patch('/:id([0-9a-fA-F-]{36})/move', async (req: Request, res: Response)
}
});
// 移动文件夹
router.patch('/move-folder', async (req: Request, res: Response) => {
try {
const { oldName, newName } = req.body;
if (!oldName || typeof oldName !== 'string') {
return res.status(400).json({ error: '原文件夹名称不能为空' });
}
if (newName !== null && typeof newName !== 'string') {
return res.status(400).json({ error: '目标文件夹名称格式错误' });
}
const trimmedOld = oldName.trim();
const trimmedNew = newName ? newName.trim() : null;
if (trimmedNew && /[\/\\:*?"<>|]/.test(trimmedNew)) {
return res.status(400).json({ error: '目标文件夹名包含非法字符' });
}
const checkResult = await query('SELECT COUNT(*) as cnt FROM files WHERE folder = $1', [trimmedOld]);
if (parseInt(checkResult.rows[0].cnt) === 0) {
return res.status(404).json({ error: '原文件夹不存在' });
}
await query('UPDATE files SET folder = $1, updated_at = NOW() WHERE folder = $2', [trimmedNew, trimmedOld]);
res.json({ success: true, folder: trimmedNew });
} catch (error) {
console.error('移动文件夹失败:', error);
res.status(500).json({ error: '移动文件夹失败' });
}
});
// 创建分享链接
router.post('/:id([0-9a-fA-F-]{36})/share', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { password, expiration } = req.body;
const result = await query('SELECT * FROM files WHERE id = $1', [id]);
const file = await getScopedFileById(id);
if (result.rows.length === 0) {
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const file = result.rows[0];
// 检查存储源是否支持分享
const supportedSources = ['onedrive', 'google_drive'];
if (!supportedSources.includes(file.source)) {
@@ -781,19 +654,18 @@ router.get('/favorites', async (req: Request, res: Response) => {
router.post('/:id([0-9a-fA-F-]{36})/favorite', async (req: Request, res: Response) => {
try {
const { id } = req.params;
// 检查文件是否存在
const result = await query('SELECT is_favorite FROM files WHERE id = $1', [id]);
if (result.rows.length === 0) {
// 检查文件是否存在,并限定当前存储源
const file = await getScopedFileById(id);
if (!file) {
return res.status(404).json({ error: '文件不存在' });
}
const currentFavorite = result.rows[0].is_favorite;
const currentFavorite = file.is_favorite;
const newFavorite = !currentFavorite;
// 更新收藏状态
await query('UPDATE files SET is_favorite = $1, updated_at = NOW() WHERE id = $2', [newFavorite, id]);
await updateScopedFileById(id, 'is_favorite = $1, updated_at = NOW()', [newFavorite]);
res.json({ success: true, isFavorite: newFavorite });
} catch (error) {
+24 -12
View File
@@ -9,6 +9,8 @@ import axios from 'axios';
import crypto from 'crypto';
import { getSetting, setSetting } from '../utils/settings.js';
import { getTelegramUserSessionFilePath, isTelegramUserClientReady } from '../services/telegramUserClient.js';
import { assertPublicStorageEndpoint } from '../utils/networkSecurity.js';
import { getCurrentStorageScope } from '../utils/fileScope.js';
// ESM compatibility
const checkDiskSpace = (checkDiskSpaceModule as any).default || checkDiskSpaceModule;
@@ -54,13 +56,15 @@ router.get('/stats', requireAuth, async (_req: Request, res: Response) => {
const diskPath = os.platform() === 'win32' ? 'C:' : path.resolve(UPLOAD_DIR);
const diskSpace = await checkDiskSpace(diskPath);
// 获取 FlClouds 使用的空间
// 获取当前存储范围内 FlClouds 使用的空间
const scope = await getCurrentStorageScope();
const result = await query(`
SELECT
SELECT
COUNT(*) as file_count,
COALESCE(SUM(size), 0) as total_size
FROM files
`);
WHERE ${scope.clause}
`, scope.params);
const flcloudsStats = result.rows[0];
@@ -90,15 +94,17 @@ router.get('/stats', requireAuth, async (_req: Request, res: Response) => {
// 获取文件类型统计
router.get('/stats/types', requireAuth, async (_req: Request, res: Response) => {
try {
const scope = await getCurrentStorageScope();
const result = await query(`
SELECT
SELECT
type,
COUNT(*) as count,
COALESCE(SUM(size), 0) as total_size
FROM files
WHERE ${scope.clause}
GROUP BY type
ORDER BY total_size DESC
`);
`, scope.params);
const stats = result.rows.map(row => ({
type: row.type,
@@ -206,7 +212,7 @@ router.get('/onedrive/callback', async (req: Request, res: Response) => {
const { code, state, error, error_description } = req.query;
if (error) {
return res.send(`授权失败: ${error_description || error}`);
return res.type('text/plain').send(`授权失败: ${String(error_description || error)}`);
}
if (!code) {
@@ -258,7 +264,7 @@ router.get('/onedrive/callback', async (req: Request, res: Response) => {
return res.status(400).send('授权失败:Microsoft 返回 AADSTS7000215,Client Secret 无效。请在 Azure/Entra「证书和密码」中新建客户端密码,并复制“值 Value”(不是“机密 ID/Secret ID”)后重新授权。');
}
return res.status(err.response?.status || 400).send(`授权失败:${errorDescription}`);
return res.status(err.response?.status || 400).type('text/plain').send(`授权失败:${String(errorDescription)}`);
}
// 尝试获取账户名称(可选,如果缺少 User.Read 权限则跳过)
@@ -353,7 +359,7 @@ router.get('/google-drive/callback', async (req: Request, res: Response) => {
const { code, state, error } = req.query;
if (error) {
return res.send(`授权失败: ${error}`);
return res.type('text/plain').send(`授权失败: ${String(error)}`);
}
if (!code) {
@@ -399,7 +405,8 @@ router.get('/google-drive/callback', async (req: Request, res: Response) => {
<body style="font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0;">
<div style="text-align: center; padding: 40px; border-radius: 20px; background: #f0fdf4; border: 1px solid #bbf7d0;">
<h2 style="color: #16a34a; margin-bottom: 10px;">🎉 授权成功!</h2>
<p style="color: #15803d; margin-bottom: 20px;">Google Drive 已成功连接并启用。</p>
<p style="color: #15803d; margin-bottom: 8px;">Google Drive 已成功连接并启用。</p>
<p style="color: #166534; font-size: 14px; margin-bottom: 20px;">窗口将自动关闭。如果未关闭,请手动关闭,主页面会自动刷新账户列表。</p>
<button onclick="window.close()" style="padding: 10px 20px; background: #16a34a; color: white; border: none; border-radius: 8px; cursor: pointer;">关闭此窗口</button>
<script>
const notifyParent = () => {
@@ -470,6 +477,8 @@ router.post('/config/s3', requireAuth, async (req: Request, res: Response) => {
return res.status(400).json({ error: '缺少必要参数' });
}
await assertPublicStorageEndpoint(endpoint);
const { storageManager } = await import('../services/storage.js');
const accountId = await storageManager.addS3Account(name, endpoint, region, accessKeyId, accessKeySecret, bucket, forcePathStyle || false);
@@ -489,6 +498,8 @@ router.post('/config/webdav', requireAuth, async (req: Request, res: Response) =
return res.status(400).json({ error: '缺少必要参数 (名称和 URL)' });
}
await assertPublicStorageEndpoint(url);
const { storageManager } = await import('../services/storage.js');
const accountId = await storageManager.addWebDAVAccount(name, url, username, password);
@@ -563,8 +574,9 @@ router.delete('/accounts/:id', requireAuth, async (req: Request, res: Response)
const accountName = accountRes.rows[0].name;
const accountType = accountRes.rows[0].type;
// 删除该账户关联的文件记录
await query('UPDATE files SET storage_account_id = NULL WHERE storage_account_id = $1', [id]);
// 删除该账户关联的 FlClouds 文件索引。不要把外部文件记录置 NULL,避免变成“本地/空账户”幽灵文件。
// 这里只清理数据库索引,不删除云端原文件;如需删除云端文件,应另做高危二次确认流程。
const fileRes = await query('DELETE FROM files WHERE storage_account_id = $1', [id]);
// 删除账户
await query('DELETE FROM storage_accounts WHERE id = $1', [id]);
@@ -573,7 +585,7 @@ router.delete('/accounts/:id', requireAuth, async (req: Request, res: Response)
storageManager.removeProvider(`${accountType}:${id}`);
console.log(`[Storage] Account deleted: ${accountName} (${id})`);
res.json({ success: true, message: `已删除账户: ${accountName}` });
res.json({ success: true, message: `已删除账户: ${accountName},已清理 ${fileRes.rowCount || 0} 条关联文件索引` });
} catch (error) {
console.error('删除账户失败:', error);
res.status(500).json({ error: '删除账户失败' });
+2 -1
View File
@@ -100,6 +100,7 @@ const handleUpload = async (req: Request, res: Response, source: string = 'web')
console.log(`[Upload] 🛠️ Current storage provider: ${provider.name}, activeAccountId: ${activeAccountId || 'none (local)'}`);
// 2. 在保存到永久存储前生成缩略图和获取尺寸
// 方案A:只在本地存储生成缩略图;OneDrive/S3/OSS/WebDAV/Google Drive 等第三方存储不生成本地缩略图。
let thumbnailPath = null;
let width = null;
let height = null;
@@ -123,7 +124,7 @@ const handleUpload = async (req: Request, res: Response, source: string = 'web')
}
}
if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) {
if (provider.name === 'local' && (mimeType.startsWith('image/') || mimeType.startsWith('video/'))) {
try {
const thumbResult = await generateThumbnail(tempPath, storedName, mimeType);
if (thumbResult) {
+12 -7
View File
@@ -394,7 +394,7 @@ export class OneDriveStorageProvider implements IStorageProvider {
name = 'onedrive';
private accessToken: string | null = null;
private tokenExpiresAt: number = 0;
private readonly ONEDRIVE_FOLDER = 'FlClouds'; // 存储文件夹名
private readonly ONEDRIVE_FOLDER = ''; // 使用第三方存储根目录,不再额外套 FlClouds 目录
constructor(
public id: string,
@@ -516,6 +516,7 @@ export class OneDriveStorageProvider implements IStorageProvider {
*/
private async ensureFolderExists(token: string, folder?: string | null): Promise<string> {
const fullFolderPath = [this.ONEDRIVE_FOLDER, folder].filter(Boolean).join('/');
if (!fullFolderPath) return '';
try {
// 使用标准的路径寻址格式,并在末尾添加冒号确保路径闭合
const endpoint = `https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(fullFolderPath)}:`;
@@ -578,7 +579,7 @@ export class OneDriveStorageProvider implements IStorageProvider {
const uploadFolder = await this.ensureFolderExists(token, folder);
// URL编码文件名,处理特殊字符
const encodedFileName = encodeURIComponent(fileName);
const targetPath = uploadFolder ? `${uploadFolder}/${fileName}` : fileName;
try {
// 小于 4MB 使用简单上传
@@ -587,7 +588,7 @@ export class OneDriveStorageProvider implements IStorageProvider {
const fileBuffer = await fs.promises.readFile(tempPath);
const response = await axios.put(
`https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(uploadFolder)}/${encodedFileName}:/content`,
`https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(targetPath)}:/content`,
fileBuffer,
{
headers: {
@@ -609,7 +610,7 @@ export class OneDriveStorageProvider implements IStorageProvider {
// 1. 创建上传会话
const sessionRes = await axios.post(
`https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(uploadFolder)}/${encodedFileName}:/createUploadSession`,
`https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(targetPath)}:/createUploadSession`,
{
item: {
"@microsoft.graph.conflictBehavior": "rename",
@@ -677,7 +678,7 @@ export class OneDriveStorageProvider implements IStorageProvider {
// 如果最后响应没有ID,查询文件信息
const itemRes = await axios.get(
`https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(uploadFolder)}/${encodedFileName}`,
`https://graph.microsoft.com/v1.0/me/drive/root:/${this.encodeOneDrivePath(targetPath)}`,
{
headers: { 'Authorization': `Bearer ${token}` },
timeout: 30000
@@ -1358,8 +1359,12 @@ export class StorageManager {
}
getProvider(name?: string): IStorageProvider {
if (name && this.providers.has(name)) {
return this.providers.get(name)!;
if (name) {
const provider = this.providers.get(name);
if (!provider) {
throw new Error(`Storage provider not found: ${name}`);
}
return provider;
}
return this.activeProvider;
}
File diff suppressed because it is too large. Load diff
+71 -17
View File
@@ -3,11 +3,47 @@ import { query } from '../db/index.js';
import { storageManager } from './storage.js';
import { getTelegramUserClient, isTelegramUserClientReady } from './telegramUserClient.js';
import { downloadTelegramChannelRange, getTelegramDownloadPreview } from './telegramUpload.js';
import { getSetting } from '../utils/settings.js';
const SUBSCRIPTION_INTERVAL_MS = Math.max(60_000, parseInt(process.env.TELEGRAM_SUBSCRIPTION_INTERVAL_MS || '300000', 10) || 300_000);
const SUBSCRIPTION_SCAN_LIMIT = Math.max(1, parseInt(process.env.TELEGRAM_SUBSCRIPTION_SCAN_LIMIT || '100', 10) || 100);
let subscriptionTimer: NodeJS.Timeout | null = null;
function parseTelegramSourceAllowlist(raw: string | undefined): string[] {
return (raw || '')
.split(',')
.map(item => item.trim())
.filter(Boolean)
.map(item => normalizeSource(item).toLowerCase());
}
async function getTelegramSourceAllowlist(): Promise<string[]> {
const envList = parseTelegramSourceAllowlist(process.env.TELEGRAM_ALLOWED_SOURCES || process.env.TELEGRAM_SOURCE_ALLOWLIST || '');
if (envList.length > 0) return envList;
const stored = await getSetting<string>('telegram_allowed_sources', '');
return parseTelegramSourceAllowlist(stored || '');
}
async function assertTelegramSourceAllowed(source: string): Promise<void> {
const normalized = normalizeSource(source).toLowerCase();
const allowlist = await getTelegramSourceAllowlist();
if (allowlist.length === 0) {
// Empty allowlist keeps compatibility for public @usernames/links, but refuses numeric/private peers.
if (/^-?\d+$/.test(normalized)) {
throw new Error('未配置 Telegram 来源白名单,禁止使用数字 ID/私聊/私密群组来源。请配置 TELEGRAM_ALLOWED_SOURCES。');
}
return;
}
if (!allowlist.includes(normalized)) {
throw new Error(`来源 ${source} 不在 Telegram 下载白名单中`);
}
}
function maxProcessedMessageId(result: { successfulMessageIds: number[]; skippedMessageIds: number[] }): number {
return Math.max(0, ...result.successfulMessageIds, ...result.skippedMessageIds);
}
function requireUserClient(): TelegramClient {
const userClient = getTelegramUserClient();
if (!userClient || !isTelegramUserClientReady()) {
@@ -157,27 +193,28 @@ async function updateJob(jobId: string, updates: Record<string, unknown>) {
await query(`UPDATE telegram_background_jobs SET ${setSql}, updated_at = NOW() WHERE id = $1`, [jobId, ...entries.map(([, value]) => value)]);
}
export async function subscribeTelegramChannel(userId: number, chatId: string | undefined, sourceInput: string) {
export async function subscribeTelegramChannel(userId: number, chatId: string | undefined, sourceInput: string, folderOverride?: string | null) {
const userClient = requireUserClient();
const source = normalizeSource(sourceInput);
await assertTelegramSourceAllowed(source);
const entity: any = await userClient.getEntity(source as any);
const latestMessageId = await getLatestMessageId(userClient, source);
const title = getEntityTitle(entity, source);
const result = await query(
`INSERT INTO telegram_channel_subscriptions (user_id, chat_id, source, title, last_message_id, enabled)
VALUES ($1, $2, $3, $4, $5, true)
`INSERT INTO telegram_channel_subscriptions (user_id, chat_id, source, title, last_message_id, folder_override, enabled)
VALUES ($1, $2, $3, $4, $5, $6, true)
ON CONFLICT (user_id, source)
DO UPDATE SET chat_id = EXCLUDED.chat_id, title = EXCLUDED.title, enabled = true, updated_at = NOW()
RETURNING id, source, title, last_message_id, enabled`,
[userId, chatId || null, source, title, latestMessageId]
DO UPDATE SET chat_id = EXCLUDED.chat_id, title = EXCLUDED.title, folder_override = EXCLUDED.folder_override, enabled = true, updated_at = NOW()
RETURNING id, source, title, last_message_id, folder_override, enabled`,
[userId, chatId || null, source, title, latestMessageId, folderOverride || null]
);
return result.rows[0];
}
export async function listTelegramSubscriptions(userId: number, includeDisabled = false) {
const result = await query(
`SELECT id, source, title, last_message_id, enabled, updated_at
`SELECT id, source, title, last_message_id, folder_override, enabled, updated_at
FROM telegram_channel_subscriptions
WHERE user_id = $1
AND ($2::boolean OR enabled = true)
@@ -187,6 +224,18 @@ export async function listTelegramSubscriptions(userId: number, includeDisabled
return result.rows;
}
export async function updateTelegramSubscriptionFolder(userId: number, selector: string, folderOverride: string | null) {
const trimmed = selector.trim();
const result = await query(
`UPDATE telegram_channel_subscriptions
SET folder_override = $2, updated_at = NOW()
WHERE user_id = $1 AND id::text LIKE $3 AND enabled = true
RETURNING id, source, title, last_message_id, folder_override, enabled`,
[userId, folderOverride || null, `${trimmed}%`]
);
return result.rows[0] || null;
}
export async function unsubscribeTelegramChannel(userId: number, selector: string) {
const trimmed = selector.trim();
const normalizedSelector = /^@|^https?:\/\//i.test(trimmed) || /^-?\d+$/.test(trimmed)
@@ -202,9 +251,10 @@ export async function unsubscribeTelegramChannel(userId: number, selector: strin
return result.rows[0] || null;
}
export async function enqueueTelegramDateDownload(botClient: TelegramClient, requestMessage: Api.Message, userId: number, sourceInput: string, startDateText: string, endDateText: string) {
export async function enqueueTelegramDateDownload(botClient: TelegramClient, requestMessage: Api.Message, userId: number, sourceInput: string, startDateText: string, endDateText: string, folderOverride?: string | null) {
const userClient = requireUserClient();
const source = normalizeSource(sourceInput);
await assertTelegramSourceAllowed(source);
const startDate = parseDateOnly(startDateText);
const endDate = parseDateOnly(endDateText, true);
if (startDate > endDate) throw new Error('开始日期不能晚于结束日期');
@@ -212,12 +262,12 @@ export async function enqueueTelegramDateDownload(botClient: TelegramClient, req
const baseMessages = await getMessagesByDateRange(userClient, source, startDate, endDate);
const messages = await expandMessagesWithMediaGroups(userClient, source, baseMessages);
const messageIds = messages.map(message => message.id);
const jobId = await createJob(userId, requestMessage.chatId?.toString(), 'date_range', source, { startDate: startDateText, endDate: endDateText, messageIds });
const jobId = await createJob(userId, requestMessage.chatId?.toString(), 'date_range', source, { startDate: startDateText, endDate: endDateText, messageIds, folderOverride: folderOverride || null });
await persistDownloadItems(jobId, source, messages);
await updateJob(jobId, { status: 'running', started_at: new Date(), total_count: messages.length });
try {
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, messages.length, 'older', messages.map(message => message.id));
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, messages.length, 'older', messages.map(message => message.id), folderOverride);
await updateDownloadItemsStatus(jobId, result.successfulMessageIds, 'success');
await updateDownloadItemsStatus(jobId, result.failedMessageIds, 'failed', '下载失败');
await updateDownloadItemsStatus(jobId, result.skippedMessageIds, 'skipped');
@@ -259,20 +309,21 @@ async function getMessagesByHashtag(userClient: TelegramClient, source: string,
return result.sort((a, b) => a.id - b.id);
}
export async function enqueueTelegramTagDownload(botClient: TelegramClient, requestMessage: Api.Message, userId: number, sourceInput: string, tagInput: string) {
export async function enqueueTelegramTagDownload(botClient: TelegramClient, requestMessage: Api.Message, userId: number, sourceInput: string, tagInput: string, folderOverride?: string | null) {
const userClient = requireUserClient();
const source = normalizeSource(sourceInput);
await assertTelegramSourceAllowed(source);
const tag = normalizeHashtag(tagInput);
const baseMessages = await getMessagesByHashtag(userClient, source, tag);
const messages = await expandMessagesWithMediaGroups(userClient, source, baseMessages);
const messageIds = messages.map(message => message.id);
const jobId = await createJob(userId, requestMessage.chatId?.toString(), 'tag_download', source, { tag, messageIds });
const jobId = await createJob(userId, requestMessage.chatId?.toString(), 'tag_download', source, { tag, messageIds, folderOverride: folderOverride || null });
await persistDownloadItems(jobId, source, messages);
await updateJob(jobId, { status: 'running', started_at: new Date(), total_count: messages.length });
try {
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, messages.length, 'older', messages.map(message => message.id));
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, messages.length, 'older', messages.map(message => message.id), folderOverride);
await updateDownloadItemsStatus(jobId, result.successfulMessageIds, 'success');
await updateDownloadItemsStatus(jobId, result.failedMessageIds, 'failed', '下载失败');
await updateDownloadItemsStatus(jobId, result.skippedMessageIds, 'skipped');
@@ -307,7 +358,7 @@ async function runSubscriptionScan(botClient: TelegramClient) {
if (!userClient || !isTelegramUserClientReady()) return;
const result = await query(
`SELECT id, user_id, chat_id, source, last_message_id
`SELECT id, user_id, chat_id, source, last_message_id, folder_override
FROM telegram_channel_subscriptions
WHERE enabled = true
ORDER BY updated_at ASC`
@@ -315,6 +366,7 @@ async function runSubscriptionScan(botClient: TelegramClient) {
for (const row of result.rows) {
try {
await assertTelegramSourceAllowed(row.source);
const latestMessageId = await getLatestMessageId(userClient, row.source);
const lastMessageId = Number(row.last_message_id || 0);
if (!latestMessageId || latestMessageId <= lastMessageId) continue;
@@ -328,7 +380,7 @@ async function runSubscriptionScan(botClient: TelegramClient) {
const targetChat = row.chat_id || row.user_id;
const requestMessage = ({ chatId: targetChat, id: latestMessageId } as unknown) as Api.Message;
const downloadResult = await downloadTelegramChannelRange(botClient, requestMessage, row.source, 0, ids.length, 'newer', ids);
const downloadResult = await downloadTelegramChannelRange(botClient, requestMessage, row.source, 0, ids.length, 'newer', ids, row.folder_override || null);
await updateDownloadItemsStatus(jobId, downloadResult.successfulMessageIds, 'success');
await updateDownloadItemsStatus(jobId, downloadResult.failedMessageIds, 'failed', '下载失败');
await updateDownloadItemsStatus(jobId, downloadResult.skippedMessageIds, 'skipped');
@@ -339,9 +391,11 @@ async function runSubscriptionScan(botClient: TelegramClient) {
error: downloadResult.failed > 0 ? `${downloadResult.failed} 个文件下载失败` : null,
finished_at: new Date(),
});
await query('UPDATE telegram_channel_subscriptions SET last_message_id = $1, updated_at = NOW() WHERE id = $2', [latestMessageId, row.id]);
const scannedMaxId = ids.length > 0 ? ids[ids.length - 1] : lastMessageId;
const safeAdvanceId = downloadResult.failed > 0 ? Math.max(lastMessageId, maxProcessedMessageId(downloadResult)) : scannedMaxId;
await query('UPDATE telegram_channel_subscriptions SET last_message_id = $1, updated_at = NOW() WHERE id = $2', [safeAdvanceId, row.id]);
if (downloadResult.found > 0) {
await botClient.sendMessage(targetChat, { message: `✅ 订阅 ${row.source} 已同步 ${downloadResult.found} 个新文件,跳过 ${downloadResult.skipped} 条。` }).catch(() => undefined);
await botClient.sendMessage(targetChat, { message: `✅ 订阅 ${row.source} 已同步 ${downloadResult.found} 个新文件,跳过 ${downloadResult.skipped} 条${downloadResult.failed ? `,失败 ${downloadResult.failed} 条` : ''}${safeAdvanceId < latestMessageId ? '。本轮达到扫描上限或存在失败项,剩余将在后续继续处理。' : '。'}` }).catch(() => undefined);
}
} catch (error) {
console.error('🤖 Telegram 订阅同步失败:', error);
+346 -116
View File
@@ -3,6 +3,7 @@ import { query } from '../db/index.js';
import checkDiskSpaceModule from 'check-disk-space';
import os from 'os';
import fs from 'fs';
import path from 'path';
import { formatBytes, getTypeEmoji } from '../utils/telegramUtils.js';
import {
MSG,
@@ -13,13 +14,26 @@ import {
buildTasksReport,
buildDeleteSuccess,
} from '../utils/telegramMessages.js';
import { authenticatedUsers, passwordInputState, isAuthenticated } from './telegramState.js';
import { authenticatedUsers, passwordInputState, isAuthenticatedAsync } from './telegramState.js';
import { forceStopDownloadTasks, getDownloadQueueStats, getTaskStatus, pauseDownloadTasks, resumeDownloadTasks, cancelDownloadTask, retryFailedDownloadTasks } from './telegramUpload.js';
import { storageManager } from './storage.js';
import { listTelegramBackgroundJobs } from './telegramChannelJobs.js';
import { getSetting, setSetting } from '../utils/settings.js';
import { DuplicateMode, getDuplicateMode } from '../utils/duplicatePolicy.js';
import { startPeriodicCleanup, stopPeriodicCleanup } from './orphanCleanup.js';
import { safeUnlink } from '../utils/localPath.js';
import { getCurrentStorageScope, nextParam, removePhysicalFile } from '../utils/fileScope.js';
import {
buildPathSettingsKeyboard,
buildPathSettingsText,
buildPendingPathPromptPersistent,
buildPathPreviewLine,
clearTelegramPathState,
getRecentTelegramPathsPersistent,
setPendingTelegramPathInput,
setNextTelegramPathPersistent,
setSessionTelegramPathPersistent,
} from '../utils/telegramPathSettings.js';
// ESM compatibility
const checkDiskSpace = (checkDiskSpaceModule as any).default || checkDiskSpaceModule;
@@ -28,6 +42,28 @@ const ON_VALUES = new Set(['1', 'true', 'yes', 'on']);
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
const THUMBNAIL_DIR = process.env.THUMBNAIL_DIR || './data/thumbnails';
interface PendingDeleteInfo {
fileId: string;
name: string;
size: number;
selector: string;
createdAt: number;
}
const pendingDeleteConfirmations = new Map<string, PendingDeleteInfo>();
const DELETE_CONFIRM_TTL_MS = 5 * 60 * 1000;
function buildDeleteConfirmKeyboard(confirmId: string): Api.ReplyInlineMarkup {
return new Api.ReplyInlineMarkup({
rows: [new Api.KeyboardButtonRow({
buttons: [
new Api.KeyboardButtonCallback({ text: '⚠️ 确认删除', data: Buffer.from(`del_confirm_${confirmId}`) }),
new Api.KeyboardButtonCallback({ text: '取消', data: Buffer.from(`del_cancel_${confirmId}`) }),
],
})],
});
}
function normalizeDownloadWorkers(value: unknown): number {
const parsed = parseInt(String(value ?? '4'), 10);
return DOWNLOAD_WORKER_OPTIONS.includes(parsed) ? parsed : 4;
@@ -70,6 +106,57 @@ function buildDownloadWorkersKeyboard(current: number, confirmValue?: number): A
});
}
function buildStorageMaintenanceKeyboard(localFileCount: number, confirm = false): Api.ReplyInlineMarkup | undefined {
if (localFileCount <= 0) return undefined;
return new Api.ReplyInlineMarkup({
rows: [
new Api.KeyboardButtonRow({
buttons: confirm
? [
new Api.KeyboardButtonCallback({ text: '⚠️ 确认删除本地全部下载文件', data: Buffer.from('storage_clear_confirm') }),
new Api.KeyboardButtonCallback({ text: '取消', data: Buffer.from('storage_clear_cancel') }),
]
: [
new Api.KeyboardButtonCallback({ text: `🧹 删除本地全部下载文件 (${localFileCount})`, data: Buffer.from('storage_clear_ask') }),
],
}),
],
});
}
async function scanLocalDownloadFiles(): Promise<{ count: number; totalSize: number; paths: string[] }> {
const baseDir = path.resolve(UPLOAD_DIR);
const paths: string[] = [];
let totalSize = 0;
if (!fs.existsSync(baseDir)) return { count: 0, totalSize: 0, paths };
async function walk(dir: string): Promise<void> {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
const stat = await fs.promises.stat(fullPath);
totalSize += stat.size;
paths.push(fullPath);
}
}
}
await walk(baseDir);
return { count: paths.length, totalSize, paths };
}
async function pruneEmptyDirs(dir: string, baseDir = path.resolve(UPLOAD_DIR)): Promise<void> {
if (!fs.existsSync(dir) || path.resolve(dir) === baseDir) return;
const entries = await fs.promises.readdir(dir);
if (entries.length === 0) {
await fs.promises.rmdir(dir);
await pruneEmptyDirs(path.dirname(dir), baseDir);
}
}
function buildDownloadWorkersText(current: number): string {
return [
'⚙️ **Telegram 并发下载设置**',
@@ -90,37 +177,8 @@ function isOn(value: unknown, defaultValue = true): boolean {
return ON_VALUES.has(String(value).toLowerCase());
}
async function getPathRuleSettings(): Promise<{ bySource: boolean; byType: boolean }> {
const bySource = await getSetting('storage_path_by_source', process.env.STORAGE_PATH_BY_SOURCE || 'true');
const byType = await getSetting('storage_path_by_type', process.env.STORAGE_PATH_BY_TYPE || 'true');
return { bySource: isOn(bySource), byType: isOn(byType) };
}
function buildPathRulesKeyboard(rules: { bySource: boolean; byType: boolean }): Api.ReplyInlineMarkup {
return new Api.ReplyInlineMarkup({
rows: [
new Api.KeyboardButtonRow({
buttons: [
new Api.KeyboardButtonCallback({ text: `${rules.bySource ? '✅' : '⬜'} 按来源/频道`, data: Buffer.from('pr_toggle_source') }),
new Api.KeyboardButtonCallback({ text: `${rules.byType ? '✅' : '⬜'} 按类型`, data: Buffer.from('pr_toggle_type') }),
],
}),
],
});
}
function buildPathRulesText(rules: { bySource: boolean; byType: boolean }): string {
const example = [rules.bySource ? 'telegram/资源下载' : null, rules.byType ? 'archives' : null].filter(Boolean).join('/') || '文件名直接保存';
return [
'📁 **保存路径规则**',
'',
`按来源/频道:${rules.bySource ? '✅ 开启' : '⬜ 关闭'}`,
`按文件类型:${rules.byType ? '✅ 开启' : '⬜ 关闭'}`,
'',
`示例:${example}`,
'',
'说明:修改后只影响后续新上传/转存文件。',
].join('\n');
async function getPathCenterState(): Promise<{ automaticBySource: boolean; automaticByType: boolean }> {
return { automaticBySource: true, automaticByType: true };
}
function buildDuplicateModeKeyboard(mode: DuplicateMode): Api.ReplyInlineMarkup {
@@ -180,8 +238,16 @@ function buildCleanupSettingsText(enabled: boolean): string {
].join('\n');
}
function getCallbackChatKey(update: Api.UpdateBotCallbackQuery): string {
const peer: any = update.peer as any;
const value = peer?.userId || peer?.chatId || peer?.channelId || update.userId;
if (value && typeof value.toJSNumber === 'function') return String(value.toJSNumber());
if (value !== undefined && value !== null) return String(value);
return String(update.userId.toJSNumber());
}
export async function handleStart(message: Api.Message, senderId: number): Promise<void> {
if (isAuthenticated(senderId)) {
if (await isAuthenticatedAsync(senderId)) {
await message.reply({ message: buildWelcomeBack() });
} else {
passwordInputState.set(senderId, { password: '' });
@@ -194,22 +260,23 @@ export async function handleHelp(message: Api.Message): Promise<void> {
export async function handleStorage(message: Api.Message): Promise<void> {
try {
const activeAccountId = storageManager.getActiveAccountId();
const scope = await getCurrentStorageScope();
const diskPath = os.platform() === 'win32' ? 'C:' : '/';
const diskSpace = await checkDiskSpace(diskPath);
// Fetch stats for the active account
const result = await query(`
SELECT COUNT(*) as file_count, COALESCE(SUM(size), 0) as total_size
FROM files
WHERE storage_account_id IS NOT DISTINCT FROM $1
`, [activeAccountId]);
SELECT COUNT(*) as file_count, COALESCE(SUM(size), 0) as total_size
FROM files
WHERE ${scope.clause}
`, scope.params);
const flcloudsStats = result.rows[0];
const totalSize = parseInt(flcloudsStats.total_size);
const fileCount = parseInt(flcloudsStats.file_count);
const usedPercent = Math.round(((diskSpace.size - diskSpace.free) / diskSpace.size) * 100);
const queueStats = getDownloadQueueStats();
const localStats = await scanLocalDownloadFiles();
const reply = buildStorageReport({
diskTotal: diskSpace.size,
@@ -217,17 +284,89 @@ export async function handleStorage(message: Api.Message): Promise<void> {
diskUsedPercent: usedPercent,
fileCount,
totalFileSize: totalSize,
localFileCount: localStats.count,
localTotalSize: localStats.totalSize,
queueActive: queueStats.active,
queuePending: queueStats.pending,
});
await message.reply({ message: reply });
await message.reply({
message: reply,
buttons: buildStorageMaintenanceKeyboard(localStats.count),
});
} catch (error) {
console.error('🤖 获取存储统计失败:', error);
await message.reply({ message: MSG.ERR_STORAGE });
}
}
export async function handleStorageCleanupCallback(client: TelegramClient, update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
const userId = update.userId.toJSNumber();
if (!(await isAuthenticatedAsync(userId))) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true }));
return;
}
try {
const stats = await scanLocalDownloadFiles();
if (data === 'storage_clear_cancel') {
await client.editMessage(update.peer, {
message: update.msgId,
text: stats.count > 0 ? `已取消清理。当前本地下载文件:${stats.count} 个,占用 ${formatBytes(stats.totalSize)}。` : '已取消清理。当前没有本地下载文件。',
buttons: buildStorageMaintenanceKeyboard(stats.count),
});
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已取消' }));
return;
}
if (data === 'storage_clear_ask') {
await client.editMessage(update.peer, {
message: update.msgId,
text: [
'⚠️ **确认删除本地服务器全部下载文件?**',
'',
`将删除 uploads 本地目录中的 **${stats.count}** 个文件,占用 **${formatBytes(stats.totalSize)}**。`,
'这只清理服务器本地下载/缓存文件,不会主动删除 OneDrive 等云端存储里的文件记录。',
'',
'如确认,请点击下方红色确认按钮。',
].join('\n'),
buttons: buildStorageMaintenanceKeyboard(stats.count, true),
});
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '需要二次确认' }));
return;
}
if (data === 'storage_clear_confirm') {
let deletedCount = 0;
let deletedBytes = 0;
for (const filePath of stats.paths) {
const size = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
if (await safeUnlink(filePath, UPLOAD_DIR)) {
deletedCount += 1;
deletedBytes += size;
await pruneEmptyDirs(path.dirname(filePath));
}
}
const after = await scanLocalDownloadFiles();
await client.editMessage(update.peer, {
message: update.msgId,
text: [
'✅ **本地服务器下载文件已清理**',
'',
`已删除:${deletedCount} 个文件`,
`释放空间:${formatBytes(deletedBytes)}`,
`剩余本地文件:${after.count} 个`,
].join('\n'),
buttons: buildStorageMaintenanceKeyboard(after.count),
});
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `已删除 ${deletedCount} 个文件` }));
}
} catch (error) {
console.error('🤖 清理本地下载文件失败:', error);
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `清理失败: ${(error as Error).message}`, alert: true }));
}
}
export async function handleList(message: Api.Message, args: string[]): Promise<void> {
try {
let limit = 10;
@@ -245,15 +384,15 @@ export async function handleList(message: Api.Message, args: string[]): Promise<
}
}
const activeAccountId = storageManager.getActiveAccountId();
const scope = await getCurrentStorageScope();
const offset = (page - 1) * limit;
const result = await query(`
SELECT id, name, type, size, folder, created_at
FROM files
WHERE storage_account_id IS NOT DISTINCT FROM $3
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`, [limit, offset, activeAccountId]);
SELECT id, name, type, size, folder, created_at
FROM files
WHERE ${scope.clause}
ORDER BY created_at DESC
LIMIT ${nextParam(scope, 1)} OFFSET ${nextParam(scope, 2)}
`, [...scope.params, limit, offset]);
if (result.rows.length === 0) {
await message.reply({ message: MSG.EMPTY_FILES });
@@ -271,7 +410,7 @@ export async function handleList(message: Api.Message, args: string[]): Promise<
export async function handleDelete(message: Api.Message, args: string[]): Promise<void> {
if (args.length === 0) {
await message.reply({
message: '❌ 请提供要删除的文件\n\n用法:\n/delete <列表序号> 例如 /delete 1\n/delete <ID前缀> 例如 /delete a1b2c3d4\n\n提示:先发送 /list 查看文件 ID 和序号。'
message: '❌ 请提供要删除的文件 ID 前缀\n\n用法:/delete <至少 8 位 ID 前缀>\n提示:请从网页端文件列表复制文件 ID。'
});
return;
}
@@ -279,78 +418,124 @@ export async function handleDelete(message: Api.Message, args: string[]): Promis
const selector = args[0].trim();
try {
const activeAccountId = storageManager.getActiveAccountId();
let result;
if (/^\d+$/.test(selector) && Number(selector) >= 1 && Number(selector) <= 50) {
result = await query(`
SELECT id, name, path, thumbnail_path, source, storage_account_id
FROM files
WHERE storage_account_id IS NOT DISTINCT FROM $2
ORDER BY created_at DESC
LIMIT 1 OFFSET $1
`, [Number(selector) - 1, activeAccountId]);
} else {
if (selector.length < 4) {
await message.reply({ message: '❌ ID 前缀至少需要 4 位。也可以用 /delete 1 删除 /list 中第 1 个文件。' });
return;
}
result = await query(`
SELECT id, name, path, thumbnail_path, source, storage_account_id
FROM files
WHERE id::text LIKE $1 AND storage_account_id IS NOT DISTINCT FROM $2
LIMIT 1
`, [selector + '%', activeAccountId]);
const scope = await getCurrentStorageScope();
if (/^\d+$/.test(selector)) {
await message.reply({ message: '❌ 为避免误删,Telegram Bot 不再支持按列表序号删除。请从网页端复制至少 8 位文件 ID 前缀。' });
return;
}
if (selector.length < 8) {
await message.reply({ message: '❌ ID 前缀至少需要 8 位。请从网页端文件列表复制更长的文件 ID。' });
return;
}
const result = await query(`
SELECT *
FROM files
WHERE ${scope.clause} AND id::text LIKE ${nextParam(scope, 1)}
ORDER BY created_at DESC
LIMIT 3
`, [...scope.params, selector + '%']);
if (result.rows.length === 0) {
await message.reply({ message: /^\d+$/.test(selector) ? `❌ 未找到列表序号 ${selector} 对应的文件` : `❌ 未找到 ID 以 "${selector}" 开头的文件` });
await message.reply({ message: `❌ 未找到 ID 以 "${selector}" 开头的文件` });
return;
}
if (result.rows.length > 1) {
await message.reply({ message: `❌ ID 前缀 "${selector}" 匹配到多个文件,请复制更长的 ID 前缀后重试。` });
return;
}
const file = result.rows[0];
// 删除实际文件
const cloudSources = ['onedrive', 'aliyun_oss', 's3', 'webdav', 'google_drive'];
if (cloudSources.includes(file.source)) {
try {
const provider = storageManager.getProvider(`${file.source}:${file.storage_account_id}`);
await provider.deleteFile(file.path);
} catch (err) {
console.warn(`🤖 ${file.source} 文件物理删除失败或文件已不存在:`, err);
}
} else if (file.path) {
await safeUnlink(file.path, UPLOAD_DIR);
}
// 删除缩略图
if (file.thumbnail_path) {
await safeUnlink(file.thumbnail_path, THUMBNAIL_DIR);
}
// 从数据库删除记录
await query(`DELETE FROM files WHERE id = $1`, [file.id]);
await message.reply({ message: buildDeleteSuccess(file.name, file.id) });
const confirmId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
pendingDeleteConfirmations.set(confirmId, { fileId: file.id, name: file.name, size: Number(file.size || 0), selector, createdAt: Date.now() });
await message.reply({
message: [
'⚠️ **确认删除这个文件?**',
'',
`📄 ${file.name}`,
`🆔 ${String(file.id).slice(0, 12)}`,
`📦 ${formatBytes(Number(file.size || 0))}`,
file.folder ? `📁 ${file.folder}` : '',
'',
'删除会移除数据库记录并尝试删除实际文件。请确认无误后点击按钮。',
].filter(Boolean).join('\n'),
buttons: buildDeleteConfirmKeyboard(confirmId),
});
} catch (error) {
console.error('🤖 删除文件失败:', error);
await message.reply({ message: `${MSG.ERR_DELETE}: ${(error as Error).message}` });
}
}
export async function handleDeleteConfirmCallback(client: TelegramClient, update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
const userId = update.userId.toJSNumber();
if (!(await isAuthenticatedAsync(userId))) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true }));
return;
}
const match = data.match(/^del_(confirm|cancel)_(.+)$/);
if (!match) return;
const [, action, confirmId] = match;
const pending = pendingDeleteConfirmations.get(confirmId);
if (!pending || Date.now() - pending.createdAt > DELETE_CONFIRM_TTL_MS) {
pendingDeleteConfirmations.delete(confirmId);
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '删除确认已过期', alert: true }));
return;
}
if (action === 'cancel') {
pendingDeleteConfirmations.delete(confirmId);
await client.editMessage(update.peer, { message: update.msgId, text: `已取消删除:${pending.name}`, buttons: new Api.ReplyInlineMarkup({ rows: [] }) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已取消' }));
return;
}
try {
const scope = await getCurrentStorageScope();
const result = await query(`SELECT * FROM files WHERE ${scope.clause} AND id = ${nextParam(scope, 1)} LIMIT 1`, [...scope.params, pending.fileId]);
const file = result.rows[0];
if (!file) {
pendingDeleteConfirmations.delete(confirmId);
await client.editMessage(update.peer, { message: update.msgId, text: '❌ 文件已不存在或不在当前存储范围内。', buttons: new Api.ReplyInlineMarkup({ rows: [] }) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '文件不存在', alert: true }));
return;
}
try { await removePhysicalFile(file); } catch (err) { console.warn('🤖 文件物理删除失败或文件已不存在:', err); }
await query('DELETE FROM files WHERE id = $1', [file.id]);
pendingDeleteConfirmations.delete(confirmId);
await client.editMessage(update.peer, { message: update.msgId, text: buildDeleteSuccess(file.name, file.id), buttons: new Api.ReplyInlineMarkup({ rows: [] }) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已删除' }));
} catch (error) {
console.error('🤖 确认删除文件失败:', error);
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `删除失败: ${(error as Error).message}`, alert: true }));
}
}
export async function handleTasks(message: Api.Message): Promise<void> {
try {
const status = getTaskStatus();
const activeCount = status.active.length;
const pendingCount = status.pending.length;
const historyCount = status.history.length;
const senderId = message.senderId?.toJSNumber();
const jobs = senderId ? await listTelegramBackgroundJobs(senderId, 5) : [];
if (activeCount === 0 && pendingCount === 0 && historyCount === 0) {
if (activeCount === 0 && pendingCount === 0 && historyCount === 0 && jobs.length === 0) {
await message.reply({ message: MSG.EMPTY_TASKS });
return;
}
const reply = buildTasksReport(status.active, status.pending, status.history);
await message.reply({ message: reply });
const sections = [buildTasksReport(status.active, status.pending, status.history)];
if (jobs.length > 0) {
sections.push([
'📡 **频道后台任务**',
...jobs.map((job: any, index: number) => [
`${index + 1}. ${job.kind} · ${job.status}`,
` 来源:${job.source}`,
` 进度:${job.enqueued_count || 0}/${job.total_count || 0} 跳过:${job.skipped_count || 0}`,
job.error ? ` ⚠️ ${job.error}` : '',
` ID: ${String(job.id).slice(0, 8)}`,
].filter(Boolean).join('\n')),
].join('\n'));
}
await message.reply({ message: sections.filter(Boolean).join('\n\n') });
} catch (error) {
console.error('🤖 获取任务列表失败:', error);
@@ -378,19 +563,19 @@ export async function handleStopTasks(message: Api.Message): Promise<void> {
export async function handlePauseTasks(message: Api.Message, args: string[] = []): Promise<void> {
const taskId = args[0];
const result = pauseDownloadTasks(taskId);
await message.reply({ message: `⏸️ 已暂停下载队列${taskId ? `\n任务: ${taskId}` : ''}\n\n进行中: ${result.active}\n等待中: ${result.pending}\n\n当前正在下载的文件会继续完成,新的等待任务暂不开始。` });
await message.reply({ message: `⏸️ 已暂停全局下载队列${taskId ? `\n任务卡: ${taskId}` : ''}\n\n进行中: ${result.active}\n等待中: ${result.pending}\n\n当前正在下载的文件会继续完成,新的等待任务暂不开始。` });
}
export async function handleResumeTasks(message: Api.Message, args: string[] = []): Promise<void> {
const taskId = args[0];
const result = resumeDownloadTasks(taskId);
await message.reply({ message: `▶️ 已继续下载队列${taskId ? `\n任务: ${taskId}` : ''}\n\n进行中: ${result.active}\n等待中: ${result.pending}` });
await message.reply({ message: `▶️ 已继续全局下载队列${taskId ? `\n任务卡: ${taskId}` : ''}\n\n进行中: ${result.active}\n等待中: ${result.pending}` });
}
export async function handleCancelTask(message: Api.Message, args: string[]): Promise<void> {
const selector = args.join(' ').trim() || 'all';
const result = cancelDownloadTask(selector);
await message.reply({ message: result.total > 0 ? `🛑 已取消任务\n\n匹配: ${selector}\n处理中: ${result.active}\n等待中: ${result.pending}` : `📮 没有找到匹配的任务:${selector}` });
await message.reply({ message: result.total > 0 ? `🛑 已取消匹配任务\n\n匹配: ${selector}\n处理中: ${result.active}\n等待中: ${result.pending}` : `📮 没有找到匹配的任务,未清空全局队列:${selector}` });
}
export async function handleRetryFailedTasks(message: Api.Message, args: string[]): Promise<void> {
@@ -415,38 +600,83 @@ export async function handleDownloadWorkers(message: Api.Message): Promise<void>
}
export async function handlePathRules(message: Api.Message): Promise<void> {
const rules = await getPathRuleSettings();
const pathCenterState = await getPathCenterState();
await message.reply({
message: buildPathRulesText(rules),
buttons: buildPathRulesKeyboard(rules),
message: buildPathSettingsText(pathCenterState, message.chatId?.toString() || 'unknown'),
buttons: buildPathSettingsKeyboard(pathCenterState),
});
}
export async function handlePathOnce(message: Api.Message, args: string[]): Promise<void> {
const folder = args.join(' ').trim();
if (!folder) {
await message.reply({ message: '❌ 用法:/p <目录>\n例如:/p PIXIV/每日Top50' });
return;
}
try {
const normalized = await setNextTelegramPathPersistent(message.chatId?.toString() || 'unknown', folder);
await message.reply({ message: `📌 已设置下一次下载目录:\`${normalized}\`\n${buildPathPreviewLine(normalized)}\n\n此设置会在下一次成功进入下载流程时自动失效。` });
} catch (error) {
await message.reply({ message: `❌ 路径无效:${(error as Error).message}` });
}
}
export async function handlePathSession(message: Api.Message, args: string[]): Promise<void> {
const folder = args.join(' ').trim();
if (!folder) {
await message.reply({ message: '❌ 用法:/ps <目录>\n例如:/ps 相册/2026-07' });
return;
}
try {
const normalized = await setSessionTelegramPathPersistent(message.chatId?.toString() || 'unknown', folder);
await message.reply({ message: `📍 已设置本会话下载目录:\`${normalized}\`\n${buildPathPreviewLine(normalized)}\n\n后续此聊天中的下载会优先保存到该目录,发送 /pc 可清除。` });
} catch (error) {
await message.reply({ message: `❌ 路径无效:${(error as Error).message}` });
}
}
export async function handlePathClear(message: Api.Message): Promise<void> {
clearTelegramPathState(message.chatId?.toString() || 'unknown');
await message.reply({ message: '🧹 已清除下一次/本会话自定义下载目录,后续恢复使用默认自动分类目录。' });
}
export async function handlePathRulesCallback(client: TelegramClient, update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
const userId = update.userId.toJSNumber();
if (!isAuthenticated(userId)) {
if (!(await isAuthenticatedAsync(userId))) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true }));
return;
}
try {
const rules = await getPathRuleSettings();
if (data === 'pr_toggle_source') {
rules.bySource = !rules.bySource;
await setSetting('storage_path_by_source', String(rules.bySource));
} else if (data === 'pr_toggle_type') {
rules.byType = !rules.byType;
await setSetting('storage_path_by_type', String(rules.byType));
const pathCenterState = await getPathCenterState();
const chatKey = getCallbackChatKey(update);
if (data === 'pr_clear_custom') {
clearTelegramPathState(chatKey);
} else if (data === 'pr_recent') {
const recent = await getRecentTelegramPathsPersistent(chatKey);
await client.sendMessage(update.peer, {
message: recent.length > 0
? ['🕘 **最近使用目录**', '', ...recent.map((item, index) => `${index + 1}. ${item}`), '', '要使用其中一个目录,请直接复制发送,或发送 `/p <目录>` / `/ps <目录>`。'].join('\n')
: '🕘 暂无最近使用目录。设置过 `/p`、`/ps`、订阅专属目录或下载任务专属目录后会自动记录。'
});
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '已发送最近目录' }));
return;
} else if (data === 'pr_help_once' || data === 'pr_help_session') {
const mode = data === 'pr_help_once' ? 'once' : 'session';
setPendingTelegramPathInput(chatKey, userId, mode);
await client.sendMessage(update.peer, { message: await buildPendingPathPromptPersistent(mode, chatKey) });
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '请直接发送目录,或发送“取消”退出' }));
return;
}
await client.editMessage(update.peer, {
message: update.msgId,
text: buildPathRulesText(rules),
buttons: buildPathRulesKeyboard(rules),
text: buildPathSettingsText(pathCenterState, chatKey),
buttons: buildPathSettingsKeyboard(pathCenterState),
});
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '保存路径规则已更新' }));
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: '保存位置已更新' }));
} catch (error) {
console.error('🤖 设置保存路径规则失败:', error);
console.error('🤖 设置保存位置失败:', error);
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `设置失败: ${(error as Error).message}`, alert: true }));
}
}
@@ -461,7 +691,7 @@ export async function handleDuplicateMode(message: Api.Message): Promise<void> {
export async function handleDuplicateModeCallback(client: TelegramClient, update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
const userId = update.userId.toJSNumber();
if (!isAuthenticated(userId)) {
if (!(await isAuthenticatedAsync(userId))) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true }));
return;
}
@@ -493,7 +723,7 @@ export async function handleCleanupSettings(message: Api.Message): Promise<void>
export async function handleCleanupSettingsCallback(client: TelegramClient, update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
const userId = update.userId.toJSNumber();
if (!isAuthenticated(userId)) {
if (!(await isAuthenticatedAsync(userId))) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true }));
return;
}
@@ -521,7 +751,7 @@ export async function handleCleanupSettingsCallback(client: TelegramClient, upda
export async function handleDownloadWorkersCallback(client: TelegramClient, update: Api.UpdateBotCallbackQuery, data: string): Promise<void> {
const userId = update.userId.toJSNumber();
if (!isAuthenticated(userId)) {
if (!(await isAuthenticatedAsync(userId))) {
await client.invoke(new Api.messages.SetBotCallbackAnswer({
queryId: update.queryId,
message: MSG.AUTH_REQUIRED,
+29 -8
View File
@@ -1,4 +1,5 @@
import { query } from '../db/index.js';
import { getConfiguredTelegramAllowedUsers } from '../utils/authSettings.js';
// Telegram User States
export enum TelegramUserState {
@@ -20,14 +21,28 @@ export const authenticatedUsers = new Map<number, { authenticatedAt: Date }>();
// Password input state
export const passwordInputState = new Map<number, { password: string }>();
export async function revokeAuthenticatedUser(userId: number): Promise<void> {
authenticatedUsers.delete(userId);
try {
await query('DELETE FROM telegram_auth WHERE user_id = $1', [userId]);
} catch (error) {
console.error('🤖 撤销 Telegram 授权用户失败:', error);
}
}
// Initialize authenticated users from database
export async function loadAuthenticatedUsers(): Promise<void> {
try {
const allowedUsers = await getConfiguredTelegramAllowedUsers();
const result = await query('SELECT user_id, authenticated_at FROM telegram_auth');
result.rows.forEach((row: any) => {
// Telegram IDs are stored as BIGINT in DB, but we use number in Map
authenticatedUsers.set(Number(row.user_id), { authenticatedAt: new Date(row.authenticated_at) });
});
for (const row of result.rows) {
const userId = Number(row.user_id);
if (allowedUsers.length > 0 && !allowedUsers.includes(userId)) {
await revokeAuthenticatedUser(userId);
continue;
}
authenticatedUsers.set(userId, { authenticatedAt: new Date(row.authenticated_at) });
}
console.log(`🤖 已从数据库载入 ${authenticatedUsers.size} 个授权用户`);
} catch (error) {
console.error('🤖 载入已验证用户失败:', error);
@@ -45,11 +60,17 @@ export async function persistAuthenticatedUser(userId: number): Promise<void> {
}
}
// Check if user is authenticated
// Fast cache-only check for legacy callers. Prefer isAuthenticatedAsync for command/callback authorization.
export function isAuthenticated(userId: number): boolean {
const ACCESS_PASSWORD_HASH = process.env.ACCESS_PASSWORD_HASH || '';
if (!ACCESS_PASSWORD_HASH) {
return true;
return authenticatedUsers.has(userId);
}
// Check if user is authenticated and still allowed by env/DB allowlist.
export async function isAuthenticatedAsync(userId: number): Promise<boolean> {
const allowedUsers = await getConfiguredTelegramAllowedUsers();
if (allowedUsers.length > 0 && !allowedUsers.includes(userId)) {
if (authenticatedUsers.has(userId)) await revokeAuthenticatedUser(userId);
return false;
}
return authenticatedUsers.has(userId);
}
+189 -70
View File
@@ -9,7 +9,7 @@ import { generateThumbnail, getImageDimensions } from '../utils/thumbnail.js';
import { storageManager } from '../services/storage.js';
import { getTelegramUserClient, isTelegramUserClientReady } from './telegramUserClient.js';
import { getSetting } from '../utils/settings.js';
import { isAuthenticated } from './telegramState.js';
import { isAuthenticatedAsync } from './telegramState.js';
import { formatBytes, getTypeEmoji, getFileType, getMimeTypeFromFilename, sanitizeFilename } from '../utils/telegramUtils.js';
import {
MSG,
@@ -32,6 +32,7 @@ import {
} from '../utils/telegramMessages.js';
import { getUniqueStoredName } from '../utils/fileUtils.js';
import { buildStorageFolderWithRules, getStoragePathRules, getTelegramBatchFolderName, getTelegramChatName, isOpaqueTelegramIdentifier } from '../utils/storagePath.js';
import { resolveTelegramStorageFolder, resolveTelegramBatchStorageFolder, resolveTelegramTaskStorageFolder, previewTelegramStorageFolder } from '../utils/telegramPathSettings.js';
import { findDuplicateFile, getDuplicateMode } from '../utils/duplicatePolicy.js';
const UPLOAD_DIR = process.env.UPLOAD_DIR || './data/uploads';
@@ -217,7 +218,10 @@ function shouldRefreshLargeTaskStatus(lastStatusRefresh: number, completed: numb
* 安全编辑消息,捕获 FloodWaitError 并更新全局冷却状态
*/
async function safeEditMessage(client: TelegramClient, chatId: Api.TypeEntityLike, params: any) {
if (Date.now() < floodWaitUntil) return null;
if (Date.now() < floodWaitUntil) {
console.warn(`[Telegram] ⏳ 跳过编辑消息:仍在 FloodWait 冷却中 chat=${chatId.toString()} msg=${params?.message}`);
return null;
}
try {
const result = await client.editMessage(chatId, params);
@@ -233,10 +237,35 @@ async function safeEditMessage(client: TelegramClient, chatId: Api.TypeEntityLik
floodWaitUntil = Date.now() + (seconds * 1000);
console.warn(`[Telegram] ⚠️ 触发 FloodWait,冷却时间: ${seconds} 秒`);
}
if (e.errorMessage === 'MESSAGE_NOT_MODIFIED' || e.message?.includes('MESSAGE_NOT_MODIFIED')) {
if (process.env.TG_STATUS_DEBUG === '1') {
console.log(`[TG][status] edit-noop chat=${chatId.toString()} msg=${params?.message}`);
}
return { notModified: true };
}
console.warn(`[Telegram] ⚠️ 编辑消息失败 chat=${chatId.toString()} msg=${params?.message}:`, e?.errorMessage || e?.message || e);
return null;
}
}
const telegramStorageWriteLocks = new Map<string, Promise<void>>();
async function withTelegramStorageWriteLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const previous = telegramStorageWriteLocks.get(key) || Promise.resolve();
let release!: () => void;
const current = new Promise<void>(resolve => { release = resolve; });
telegramStorageWriteLocks.set(key, previous.then(() => current, () => current));
await previous.catch(() => undefined);
try {
return await fn();
} finally {
release();
if (telegramStorageWriteLocks.get(key) === current) {
telegramStorageWriteLocks.delete(key);
}
}
}
const silentNoticePromiseMap = new Map<string, Promise<any>>();
async function ensureSilentNotice(client: TelegramClient, chatId: Api.TypeEntityLike, fileCount: number, replyToMsg?: Api.Message) {
@@ -572,7 +601,13 @@ interface SilentSession {
}
const silentSessionMap = new Map<string, SilentSession>();
interface TaskControlScope {
chatId: string;
userId?: number;
}
const taskIdToChatId = new Map<string, string>();
const taskIdControlScopes = new Map<string, TaskControlScope>();
function createSessionTaskId(): string {
return `t${Date.now().toString(36).slice(-5)}${Math.random().toString(36).slice(2, 5)}`;
@@ -587,13 +622,33 @@ function resolveTaskChatId(taskId?: string): string | undefined {
return taskIdToChatId.get(taskId.trim());
}
function registerTaskControlScope(taskId: string, chatId: string, userId?: number): void {
taskIdToChatId.set(taskId, chatId);
taskIdControlScopes.set(taskId, { chatId, userId });
}
function removeTaskControlScope(taskId?: string): void {
if (!taskId) return;
taskIdToChatId.delete(taskId);
taskIdControlScopes.delete(taskId);
}
export function canControlTask(taskId: string | undefined, chatId: string | undefined, userId?: number): boolean {
if (!taskId || !chatId) return false;
const scope = taskIdControlScopes.get(taskId.trim());
if (!scope) return false;
if (scope.chatId !== chatId) return false;
if (scope.userId !== undefined && userId !== undefined && scope.userId !== userId) return false;
return true;
}
function getSilentSession(chatIdStr: string): SilentSession {
let s = silentSessionMap.get(chatIdStr);
if (!s) {
const taskId = createSessionTaskId();
s = { total: 0, completed: 0, failed: 0, taskId, knownTaskKeys: new Set(), knownTaskCounts: new Map(), folders: new Set(), providers: new Set() };
silentSessionMap.set(chatIdStr, s);
taskIdToChatId.set(taskId, chatIdStr);
registerTaskControlScope(taskId, chatIdStr);
}
return s;
}
@@ -602,7 +657,7 @@ function startSilentSession(chatIdStr: string, total: number): SilentSession {
const taskId = createSessionTaskId();
const s = { total, completed: 0, failed: 0, taskId, knownTaskKeys: new Set<string>(), knownTaskCounts: new Map<string, number>(), folders: new Set<string>(), providers: new Set<string>() };
silentSessionMap.set(chatIdStr, s);
taskIdToChatId.set(taskId, chatIdStr);
registerTaskControlScope(taskId, chatIdStr);
return s;
}
@@ -617,7 +672,7 @@ async function finalizeSilentSessionIfDone(client: TelegramClient, chatId: Api.T
const s = silentSessionMap.get(chatIdStr);
const silentMsgId = silentNoticeMessageIdMap.get(chatIdStr);
// 编辑静默通知为完成消息
// 编辑静默通知为完成消息;如果编辑失败,补发一条完成消息,避免 Telegram 卡在旧进度。
if (silentMsgId) {
const text = buildSilentAllTasksComplete(
s?.total || 0,
@@ -638,13 +693,23 @@ async function finalizeSilentSessionIfDone(client: TelegramClient, chatId: Api.T
})),
],
);
const controls = s?.failed ? buildTaskControlButtons(s?.taskId) : new Api.ReplyInlineMarkup({ rows: [] });
await safeEditMessage(client, chatId, { message: silentMsgId, text, buttons: controls });
// GramJS/Telegram rejects ReplyInlineMarkup with an empty rows array (REPLY_MARKUP_INVALID).
// Omit buttons entirely to clear inline controls on successful completion.
const controls = s?.failed ? buildTaskControlButtons(s?.taskId) : undefined;
const edited = await safeEditMessage(client, chatId, { message: silentMsgId, text, buttons: controls });
if (!edited) {
try {
await client.sendMessage(chatId, { message: text, buttons: controls });
console.warn(`[TG][silent] completion-edit-failed fallback-sent chat=${chatIdStr} oldMsg=${silentMsgId}`);
} catch (e) {
console.error(`[TG][silent] completion-fallback-send-failed chat=${chatIdStr}:`, e);
}
}
}
// 清理静默状态
silentSessionMap.delete(chatIdStr);
if (s?.taskId) taskIdToChatId.delete(s.taskId);
removeTaskControlScope(s?.taskId);
silentNoticeMessageIdMap.delete(chatIdStr);
lastSilentNotificationTimeMap.delete(chatIdStr);
@@ -987,7 +1052,7 @@ export async function refreshSilentProgress(client: TelegramClient, chatId: Api.
queueStats.pauseReason,
);
const buttons = isComplete && session.failed === 0
? new Api.ReplyInlineMarkup({ rows: [] })
? undefined
: buildTaskControlButtons(session.taskId);
await safeEditMessage(client, chatId, { message: silentMsgId, text, buttons });
}
@@ -1004,7 +1069,7 @@ async function checkAndResetSession(client: TelegramClient, chatId: Api.TypeEnti
if (outstanding === 0 && silentSessionMap.has(chatIdStr)) {
const zombieTaskId = getSessionTaskId(chatIdStr);
silentSessionMap.delete(chatIdStr);
if (zombieTaskId) taskIdToChatId.delete(zombieTaskId);
removeTaskControlScope(zombieTaskId);
silentNoticeMessageIdMap.delete(chatIdStr);
lastSilentNotificationTimeMap.delete(chatIdStr);
console.log(`[TG][silent] Auto-cleared zombie session for ${chatIdStr}`);
@@ -1084,28 +1149,33 @@ export function forceStopDownloadTasks(reason?: string) {
return downloadQueue.forceStopAll(reason);
}
export function pauseDownloadTasks(_taskId?: string) {
// 当前底层下载队列是全局队列;taskId 仅用于用户侧定位任务提示。
export function pauseDownloadTasks(taskId?: string) {
// 当前底层下载队列仍是全局队列。带 taskId 的操作只允许已登记任务触发,文案明确其全局影响。
if (taskId && !resolveTaskChatId(taskId)) return { active: 0, pending: 0, total: 0 };
return downloadQueue.pause();
}
export function resumeDownloadTasks(_taskId?: string) {
// 当前底层下载队列是全局队列;taskId 仅用于用户侧定位任务提示。
export function resumeDownloadTasks(taskId?: string) {
if (taskId && !resolveTaskChatId(taskId)) return { active: 0, pending: 0, total: 0 };
return downloadQueue.resume();
}
export function cancelDownloadTask(selector?: string) {
const normalized = selector?.trim();
if (normalized && resolveTaskChatId(normalized)) {
return downloadQueue.forceStopAll(`用户通过 /task_cancel ${normalized} 取消任务`);
// Until the queue is fully per-session, refuse to translate a task card into forceStopAll.
return downloadQueue.cancel('no-such-task-selector', `用户通过 /task_cancel ${normalized} 取消任务`);
}
return downloadQueue.cancel(normalized, '用户通过 /task_cancel 取消任务');
}
export async function cancelSilentTask(client: TelegramClient, chatId: Api.TypeEntityLike, taskId: string, fallbackMessageId?: number) {
export async function cancelSilentTask(client: TelegramClient, chatId: Api.TypeEntityLike, taskId: string, fallbackMessageId?: number, userId?: number) {
const mappedChatId = resolveTaskChatId(taskId);
const chatIdStr = mappedChatId || chatId.toString();
const editChatId = mappedChatId || chatId;
if (!canControlTask(taskId, chatIdStr, userId)) {
throw new Error('任务不属于当前聊天或已失效');
}
const session = silentSessionMap.get(chatIdStr);
const silentMsgId = silentNoticeMessageIdMap.get(chatIdStr) || fallbackMessageId;
const result = cancelDownloadTask(taskId);
@@ -1129,8 +1199,8 @@ export async function cancelSilentTask(client: TelegramClient, chatId: Api.TypeE
}
silentSessionMap.delete(chatIdStr);
if (session?.taskId) taskIdToChatId.delete(session.taskId);
taskIdToChatId.delete(taskId);
removeTaskControlScope(session?.taskId);
removeTaskControlScope(taskId);
silentNoticeMessageIdMap.delete(chatIdStr);
lastSilentNotificationTimeMap.delete(chatIdStr);
clearConsolidatedState(chatIdStr);
@@ -1155,6 +1225,7 @@ interface FileUploadItem {
error?: string;
retried?: boolean; // 是否已重试过
targetDir?: string; // 批量上传时文件的目标目录
folderOverride?: string | null; // 任务级保存目录覆盖,不影响全局路径规则
}
interface MediaGroupQueue {
@@ -1165,6 +1236,7 @@ interface MediaGroupQueue {
createdAt: number;
folderName?: string; // 多文件上传的文件夹名称(来自消息 caption)
folderPath?: string; // 实际创建的文件夹路径
storageFolder?: string | null; // 批量任务整批共用的第三方存储目录
}
// 多文件上传队列 (key: mediaGroupId)
@@ -1437,19 +1509,23 @@ async function processFileUpload(client: TelegramClient, file: FileUploadItem, q
let storedName: string | undefined; // This will hold the unique name for final storage
try {
// 获取唯一的存储文件名
const activeAccountId = storageManager.getActiveAccountId();
const chatName = await getTelegramChatName(file.message);
const batchFolder = null;
const storageRules = await getStoragePathRules();
const storageFolder = buildStorageFolderWithRules({
const automaticFolder = buildStorageFolderWithRules({
source: 'telegram',
chatName,
folder: batchFolder,
mimeType: file.mimeType,
fileName: file.fileName,
}, storageRules);
storedName = await getUniqueStoredName(file.fileName, storageFolder, activeAccountId);
const chatIdForPath = queue?.chatId?.toString() || file.message.chatId?.toString() || 'unknown';
const storageFolder = file.folderOverride !== undefined
? file.folderOverride
: queue?.storageFolder !== undefined
? queue.storageFolder
: resolveTelegramStorageFolder(chatIdForPath, automaticFolder);
const downloadSource = await resolveDownloadSource(client, file.message);
const result = await downloadAndSaveFile(downloadSource.client, downloadSource.message, file.fileName, file.targetDir, undefined, signal);
@@ -1459,7 +1535,6 @@ async function processFileUpload(client: TelegramClient, file: FileUploadItem, q
}
localFilePath = result.filePath;
// storedName 已在上方生成,结果中的 storedName 是原始名,由于我们已手动生成唯一名,忽略
const actualSize = result.actualSize;
const fileType = getFileType(file.mimeType);
const duplicateMode = await getDuplicateMode();
@@ -1480,46 +1555,53 @@ async function processFileUpload(client: TelegramClient, file: FileUploadItem, q
}
}
// 生成缩略图和获取尺寸
let thumbnailPath: string | null = null;
let dimensions: { width?: number; height?: number } = {};
try {
thumbnailPath = await generateThumbnail(localFilePath, storedName, file.mimeType);
dimensions = await getImageDimensions(localFilePath, file.mimeType);
} catch (thumbErr) {
console.warn('🤖 生成缩略图/获取尺寸失败,继续上传:', thumbErr);
}
const provider = storageManager.getProvider();
let finalPath = localFilePath;
let sourceRef = provider.name;
const storageLockKey = `${provider.name}:${activeAccountId || 'local'}:${storageFolder || ''}`;
return await withTelegramStorageWriteLock(storageLockKey, async () => {
storedName = await getUniqueStoredName(file.fileName, storageFolder, activeAccountId);
try {
finalPath = await provider.saveFile(localFilePath, storedName, file.mimeType, storageFolder);
if (fs.existsSync(localFilePath)) {
fs.unlinkSync(localFilePath);
// 生成缩略图和获取尺寸。方案A:只在本地存储生成;第三方存储不生成本地缩略图。
let thumbnailPath: string | null = null;
let dimensions: { width?: number; height?: number } = {};
if (provider.name === 'local' && (file.mimeType.startsWith('image/') || file.mimeType.startsWith('video/'))) {
try {
thumbnailPath = await generateThumbnail(localFilePath!, storedName, file.mimeType);
dimensions = await getImageDimensions(localFilePath!, file.mimeType);
} catch (thumbErr) {
console.warn('🤖 生成缩略图/获取尺寸失败,继续上传:', thumbErr);
}
}
localFilePath = undefined;
} catch (err) {
console.error('保存文件到存储提供商失败:', err);
throw err;
}
await query(`
INSERT INTO files (name, stored_name, type, mime_type, size, path, thumbnail_path, width, height, source, folder, storage_account_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [file.fileName, storedName, fileType, file.mimeType, actualSize, finalPath, thumbnailPath, dimensions.width, dimensions.height, sourceRef, storageFolder, activeAccountId]);
let finalPath = localFilePath!;
let sourceRef = provider.name;
file.status = 'success';
file.size = actualSize;
file.fileType = fileType;
if (queue?.chatId) {
const chatIdStr = queue.chatId.toString();
const batchId = (file.message as any).groupedId?.toString();
if (batchId) updateBatch(chatIdStr, batchId, { folderPath: storageFolder || undefined, providerName: provider.name });
rememberTransferDestination(chatIdStr, storageFolder, provider.name);
}
return true;
try {
finalPath = await provider.saveFile(localFilePath!, storedName, file.mimeType, storageFolder);
if (fs.existsSync(localFilePath!)) {
fs.unlinkSync(localFilePath!);
}
localFilePath = undefined;
} catch (err) {
console.error('保存文件到存储提供商失败:', err);
throw err;
}
await query(`
INSERT INTO files (name, stored_name, type, mime_type, size, path, thumbnail_path, width, height, source, folder, storage_account_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [file.fileName, storedName, fileType, file.mimeType, actualSize, finalPath, thumbnailPath, dimensions.width, dimensions.height, sourceRef, storageFolder, activeAccountId]);
file.status = 'success';
file.size = actualSize;
file.fileType = fileType;
if (queue?.chatId) {
const chatIdStr = queue.chatId.toString();
const batchId = (file.message as any).groupedId?.toString();
if (batchId) updateBatch(chatIdStr, batchId, { folderPath: storageFolder || undefined, providerName: provider.name });
rememberTransferDestination(chatIdStr, storageFolder, provider.name);
}
return true;
});
} catch (error) {
console.error('🤖 文件上传失败:', error);
@@ -1634,13 +1716,16 @@ async function processBatchUpload(client: TelegramClient, mediaGroupId: string):
const chatName = await getTelegramChatName(firstBatchFile.message);
const batchFolder = null;
const storageRules = await getStoragePathRules();
const folderPreview = buildStorageFolderWithRules({
const automaticPreview = buildStorageFolderWithRules({
source: 'telegram',
chatName,
folder: batchFolder,
mimeType: firstBatchFile.mimeType,
fileName: firstBatchFile.fileName,
}, storageRules);
const storageFolder = resolveTelegramBatchStorageFolder(chatId.toString(), automaticPreview);
queue.storageFolder = storageFolder;
const folderPreview = storageFolder;
updateBatch(chatId.toString(), batchId, { folderName: queue.folderName, folderPath: folderPreview || undefined });
queue.folderPath = folderPreview || undefined;
rememberTransferDestination(chatId.toString(), folderPreview, storageManager.getProvider().name);
@@ -1778,12 +1863,13 @@ export async function getTelegramDownloadPreview(messages: Api.Message[]): Promi
}
mediaCount += 1;
const chatName = await getTelegramChatName(message);
const storageFolder = buildStorageFolderWithRules({
const automaticFolder = buildStorageFolderWithRules({
source: 'telegram',
chatName,
mimeType: fileInfo.mimeType,
fileName: fileInfo.fileName,
}, storageRules);
const storageFolder = previewTelegramStorageFolder(message.chatId?.toString() || 'unknown', automaticFolder);
const duplicate = await findDuplicateFile(fileInfo.fileName, storageFolder, getEstimatedFileSize(message), activeAccountId);
if (duplicate) duplicateCount += 1;
}
@@ -1804,6 +1890,7 @@ export async function downloadTelegramChannelRange(
limit: number = 50,
direction: 'older' | 'newer' = 'older',
explicitIds?: number[],
folderOverride?: string | null,
): Promise<{ requested: number; found: number; skipped: number; failed: number; successful: number; successfulMessageIds: number[]; failedMessageIds: number[]; skippedMessageIds: number[]; firstId: number; lastId: number }> {
const userClient = getTelegramUserClient();
if (!userClient || !isTelegramUserClientReady()) {
@@ -1833,6 +1920,8 @@ export async function downloadTelegramChannelRange(
let found = 0;
let skipped = 0;
const chatIdStr = chatId.toString();
const taskFolderOverride = folderOverride !== undefined ? folderOverride : undefined;
let taskResolvedStorageFolder: string | null | undefined;
const downloadableRefs: DownloadableMessageRef[] = [];
const successfulMessageIds: number[] = [];
const failedMessageIds: number[] = [];
@@ -1863,10 +1952,27 @@ export async function downloadTelegramChannelRange(
const batchId = `tg-range-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
if (downloadableRefs.length > 0) {
const firstRef = downloadableRefs[0];
if (taskFolderOverride !== undefined) {
taskResolvedStorageFolder = taskFolderOverride;
} else if (firstRef) {
const firstMessage = (await userClient.getMessages(sourceEntity as any, { ids: [firstRef.id] }))[0] as Api.Message | undefined;
if (firstMessage) {
const chatName = await getTelegramChatName(firstMessage);
const storageRules = await getStoragePathRules();
const automaticPreview = buildStorageFolderWithRules({
source: 'telegram',
chatName,
mimeType: firstRef.fileInfo.mimeType,
fileName: firstRef.fileInfo.fileName,
}, storageRules);
taskResolvedStorageFolder = resolveTelegramTaskStorageFolder(chatIdStr, automaticPreview).folder;
}
}
registerBatch(chatIdStr, batchId, {
id: batchId,
folderName: sourceEntity.toString(),
folderPath: undefined,
folderPath: taskResolvedStorageFolder || undefined,
totalFiles: downloadableRefs.length,
completed: 0,
successful: 0,
@@ -1931,17 +2037,22 @@ export async function downloadTelegramChannelRange(
status: 'pending',
};
try {
if (!getConsolidatedBatches(chatIdStr).find(batch => batch.id === batchId)?.folderPath) {
if (taskResolvedStorageFolder !== undefined) {
updateBatch(chatIdStr, batchId, { folderPath: taskResolvedStorageFolder || undefined });
} else if (!getConsolidatedBatches(chatIdStr).find(batch => batch.id === batchId)?.folderPath) {
const chatName = await getTelegramChatName(message);
const storageRules = await getStoragePathRules();
const folderPreview = buildStorageFolderWithRules({
const automaticPreview = buildStorageFolderWithRules({
source: 'telegram',
chatName,
mimeType,
fileName,
}, storageRules);
updateBatch(chatIdStr, batchId, { folderPath: folderPreview || undefined });
const resolved = resolveTelegramTaskStorageFolder(chatIdStr, automaticPreview).folder;
taskResolvedStorageFolder = resolved;
updateBatch(chatIdStr, batchId, { folderPath: resolved || undefined });
}
uploadItem.folderOverride = taskResolvedStorageFolder !== undefined ? taskResolvedStorageFolder : taskFolderOverride;
await processFileUpload(userClient, uploadItem);
if (uploadItem.status === 'success') {
successful += 1;
@@ -1990,7 +2101,7 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage
const senderId = message.senderId?.toJSNumber();
if (!senderId) return;
if (!isAuthenticated(senderId)) {
if (!(await isAuthenticatedAsync(senderId))) {
await message.reply({ message: MSG.AUTH_REQUIRED_UPLOAD });
return;
}
@@ -2057,6 +2168,8 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage
if (message.chatId) {
await trySilentMode(client, message.chatId, message);
const taskId = getSessionTaskId(message.chatId.toString());
if (taskId) registerTaskControlScope(taskId, message.chatId.toString(), senderId);
}
} else {
let finalFileName = fileName;
@@ -2094,6 +2207,8 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage
const useConsolidated = () => getActiveUploadCount(chatIdStr) >= 2 || getActiveBatchCount(chatIdStr) > 0;
await trySilentMode(client, chatId, message);
const silentTaskId = getSessionTaskId(chatIdStr);
if (silentTaskId) registerTaskControlScope(silentTaskId, chatIdStr, senderId);
if (!silentSessionMap.has(chatIdStr) && getBackgroundFileCount(chatIdStr) <= 3) {
await runStatusAction(chatId, async () => {
@@ -2162,12 +2277,13 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage
const activeAccountId = storageManager.getActiveAccountId();
const chatName = await getTelegramChatName(message);
const storageRules = await getStoragePathRules();
const storageFolder = buildStorageFolderWithRules({
const automaticFolder = buildStorageFolderWithRules({
source: 'telegram',
chatName,
mimeType,
fileName: finalFileName,
}, storageRules);
const storageFolder = resolveTelegramStorageFolder(chatIdStr, automaticFolder);
// 关键在这里:获取唯一文件名
const storedName = await getUniqueStoredName(finalFileName, storageFolder, activeAccountId);
@@ -2219,14 +2335,17 @@ export async function handleFileUpload(client: TelegramClient, event: NewMessage
});
}
const provider = storageManager.getProvider();
let thumbnailPath: string | null = null;
let dimensions: { width?: number; height?: number } = {};
try {
thumbnailPath = await generateThumbnail(localFilePath, storedName, mimeType);
dimensions = await getImageDimensions(localFilePath, mimeType);
} catch (thumbErr) { }
// 方案A:只在本地存储生成缩略图;第三方存储不生成本地缩略图。
if (provider.name === 'local' && (mimeType.startsWith('image/') || mimeType.startsWith('video/'))) {
try {
thumbnailPath = await generateThumbnail(localFilePath, storedName, mimeType);
dimensions = await getImageDimensions(localFilePath, mimeType);
} catch (thumbErr) { }
}
const provider = storageManager.getProvider();
let finalPath = localFilePath;
let sourceRef = provider.name;
+7 -4
View File
@@ -160,10 +160,13 @@ async function uploadDownloadedFile(localFilePath: string, originalFileName: str
let thumbnailPath: string | null = null;
let dimensions: { width?: number; height?: number } = {};
try {
thumbnailPath = await generateThumbnail(localFilePath, storedName, mimeType);
dimensions = await getImageDimensions(localFilePath, mimeType);
} catch {
// 方案A:只在本地存储生成缩略图;第三方存储不生成本地缩略图。
if (provider.name === 'local' && (mimeType.startsWith('image/') || mimeType.startsWith('video/'))) {
try {
thumbnailPath = await generateThumbnail(localFilePath, storedName, mimeType);
dimensions = await getImageDimensions(localFilePath, mimeType);
} catch {
}
}
let finalPath = await provider.saveFile(localFilePath, storedName, mimeType, folder);
+121
View File
@@ -0,0 +1,121 @@
import crypto from 'crypto';
import { getSetting, setSetting } from './settings.js';
const WEB_PASSWORD_KEY = 'admin_password_hash';
const TELEGRAM_PIN_KEY = 'telegram_pin_hash';
const TELEGRAM_ALLOWED_USERS_KEY = 'telegram_allowed_user_ids';
const SCRYPT_PREFIX = 'scrypt:v1';
function hashSecret(secret: string): string {
const salt = crypto.randomBytes(16).toString('base64url');
const derived = crypto.scryptSync(secret, salt, 64).toString('base64url');
return `${SCRYPT_PREFIX}:${salt}:${derived}`;
}
function safeEqualText(a: string, b: string): boolean {
try {
const left = Buffer.from(a);
const right = Buffer.from(b);
return left.length === right.length && crypto.timingSafeEqual(left, right);
} catch {
return false;
}
}
function verifySecret(secret: string, stored: string): boolean {
if (!stored) return false;
if (stored.startsWith(`${SCRYPT_PREFIX}:`)) {
const [, , salt, expected] = stored.split(':');
if (!salt || !expected) return false;
const actual = crypto.scryptSync(secret, salt, 64).toString('base64url');
return safeEqualText(actual, expected);
}
// Legacy compatibility: ACCESS_PASSWORD_HASH used SHA-256 hex.
if (/^[a-f0-9]{64}$/i.test(stored)) {
const actual = crypto.createHash('sha256').update(secret).digest('hex');
return safeEqualText(actual, stored.toLowerCase());
}
return false;
}
export async function getStoredWebPasswordHash(): Promise<string> {
const stored = await getSetting<string>(WEB_PASSWORD_KEY, '');
return stored || process.env.ACCESS_PASSWORD_HASH || '';
}
export async function isInitialSetupRequired(): Promise<boolean> {
return !(await getStoredWebPasswordHash());
}
export async function verifyWebPassword(password: string): Promise<boolean> {
return verifySecret(password, await getStoredWebPasswordHash());
}
export async function verifyTelegramPin(pin: string): Promise<boolean> {
const stored = await getSetting<string>(TELEGRAM_PIN_KEY, '');
if (stored) return verifySecret(pin, stored);
// Migration fallback for old deployments that used the web password in Bot.
return verifySecret(pin, await getStoredWebPasswordHash());
}
export function validateWebPassword(password: unknown): string | null {
if (typeof password !== 'string' || password.length < 8) {
return '网页管理员密码至少需要 8 位';
}
if (password.length > 256) {
return '网页管理员密码过长';
}
return null;
}
export function validateTelegramPin(pin: unknown): string | null {
if (typeof pin !== 'string' || !/^\d{4}$/.test(pin)) {
return 'Telegram Bot 密码必须是 4 位数字';
}
return null;
}
export async function createInitialAdminCredentials(webPassword: string, telegramPin: string): Promise<void> {
if (!(await isInitialSetupRequired())) {
throw new Error('管理员密码已创建,不能重复初始化');
}
const webError = validateWebPassword(webPassword);
if (webError) throw new Error(webError);
const pinError = validateTelegramPin(telegramPin);
if (pinError) throw new Error(pinError);
if (webPassword === telegramPin) {
throw new Error('网页密码不能与 Telegram Bot 4 位密码相同');
}
await setSetting(WEB_PASSWORD_KEY, hashSecret(webPassword));
await setSetting(TELEGRAM_PIN_KEY, hashSecret(telegramPin));
}
function parseUserIds(value: string | null | undefined): number[] {
if (!value) return [];
return [...new Set(String(value)
.split(',')
.map(item => Number(item.trim()))
.filter(item => Number.isSafeInteger(item) && item > 0))];
}
export async function getConfiguredTelegramAllowedUsers(): Promise<number[]> {
const envUsers = parseUserIds(process.env.TELEGRAM_ALLOWED_USER_IDS || '');
if (envUsers.length > 0) return envUsers;
const stored = await getSetting<string>(TELEGRAM_ALLOWED_USERS_KEY, '');
return parseUserIds(stored || '');
}
export async function addTelegramAllowedUser(userId: number): Promise<number[]> {
const users = await getConfiguredTelegramAllowedUsers();
if (!users.includes(userId)) users.push(userId);
await setSetting(TELEGRAM_ALLOWED_USERS_KEY, users.join(','));
return users;
}
+2
View File
@@ -15,6 +15,8 @@ const SENSITIVE_SETTING_KEYS = new Set([
'onedrive_refresh_token',
'google_drive_client_secret',
'google_drive_refresh_token',
'admin_password_hash',
'telegram_pin_hash',
]);
function getCredentialSecret(): string {
+63
View File
@@ -0,0 +1,63 @@
import path from 'path';
import { query } from '../db/index.js';
import { safeUnlink } from './localPath.js';
export const CLOUD_SOURCES = new Set(['onedrive', 'aliyun_oss', 's3', 'webdav', 'google_drive']);
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || './data/uploads');
const THUMBNAIL_DIR = path.resolve(process.env.THUMBNAIL_DIR || './data/thumbnails');
export interface StorageScope {
clause: string;
params: any[];
}
export async function getCurrentStorageScope(): Promise<StorageScope> {
const { storageManager } = await import('../services/storage.js');
const provider = storageManager.getProvider();
if (provider.name === 'local') {
return { clause: "source = 'local'", params: [] };
}
return { clause: 'storage_account_id = $1', params: [storageManager.getActiveAccountId()] };
}
export function nextParam(scope: StorageScope, offset: number): string {
return `$${scope.params.length + offset}`;
}
export async function getScopedFileById(id: string): Promise<any | null> {
const scope = await getCurrentStorageScope();
const result = await query(
`SELECT * FROM files WHERE ${scope.clause} AND id = ${nextParam(scope, 1)}`,
[...scope.params, id]
);
return result.rows[0] || null;
}
export async function removePhysicalFile(file: any): Promise<void> {
if (CLOUD_SOURCES.has(file.source)) {
const { storageManager } = await import('../services/storage.js');
const provider = storageManager.getProvider(`${file.source}:${file.storage_account_id}`);
await provider.deleteFile(file.path);
} else {
const filePath = file.path || path.join(UPLOAD_DIR, file.stored_name);
await safeUnlink(filePath, UPLOAD_DIR);
}
if (file.thumbnail_path) {
const thumbPath = path.join(THUMBNAIL_DIR, path.basename(file.thumbnail_path));
await safeUnlink(thumbPath, THUMBNAIL_DIR);
}
}
export async function updateScopedFileById(id: string, setSql: string, values: any[]): Promise<number> {
const scope = await getCurrentStorageScope();
const idParam = nextParam(scope, values.length + 1);
const result = await query(
`UPDATE files SET ${setSql} WHERE ${scope.clause} AND id = ${idParam}`,
[...scope.params, ...values, id]
);
return result.rowCount || 0;
}
+16
View File
@@ -53,3 +53,19 @@ export async function assertPublicHttpUrl(rawUrl: string): Promise<URL> {
}
return parsed;
}
export async function assertPublicHttpsUrl(rawUrl: string): Promise<URL> {
const parsed = await assertPublicHttpUrl(rawUrl);
if (parsed.protocol !== 'https:') {
throw new Error('生产存储端点仅允许 https 链接');
}
return parsed;
}
export async function assertPublicStorageEndpoint(rawUrl: string): Promise<URL> {
const parsed = await assertPublicHttpUrl(rawUrl);
if (parsed.protocol !== 'https:' && process.env.ALLOW_INSECURE_STORAGE_ENDPOINTS !== 'true') {
throw new Error('存储端点仅允许 https;如确需 http,请显式设置 ALLOW_INSECURE_STORAGE_ENDPOINTS=true');
}
return parsed;
}
+3 -13
View File
@@ -133,17 +133,7 @@ export async function generateOTPAuthUrl(user: string = 'Admin'): Promise<string
* 优先读取 Cloudflare 的真实 IP 头,其次读取标准代理头 x-forwarded-for
*/
export function getClientIP(req: any): string {
// Cloudflare 真实 IP
const cfIP = req.headers['cf-connecting-ip'];
if (cfIP) return Array.isArray(cfIP) ? cfIP[0] : cfIP;
// 标准代理头
const xForwardedFor = req.headers['x-forwarded-for'];
if (xForwardedFor) {
const ips = Array.isArray(xForwardedFor) ? xForwardedFor : xForwardedFor.split(',');
return ips[0].trim();
}
// fallback 到 express 自带的 ip
return req.ip || '未知';
// Only trust Express' proxy-aware req.ip. app.set('trust proxy', ...) controls which
// proxy headers are accepted, so direct clients cannot spoof x-forwarded-for here.
return req.ip || req.socket?.remoteAddress || '未知';
}
+3 -6
View File
@@ -98,12 +98,9 @@ export function getDetailedTypeFolder(mimeType?: string | null, fileName?: strin
}
export async function getStoragePathRules(): Promise<StoragePathRules> {
const bySource = await getSetting('storage_path_by_source', process.env.STORAGE_PATH_BY_SOURCE ?? 'true');
const byType = await getSetting('storage_path_by_type', process.env.STORAGE_PATH_BY_TYPE ?? 'true');
return {
bySource: isEnabled(String(bySource ?? 'true'), true),
byType: isEnabled(String(byType ?? 'true'), true),
};
// 默认自动分类:来源/频道 + 文件类型。
// 自定义保存路径由调用方直接覆盖,保存到用户指定目录本身,不再追加频道名或类型目录。
return { bySource: true, byType: true };
}
function normalizeSegment(value: string | null | undefined, fallback: string): string {
+31 -29
View File
@@ -26,17 +26,17 @@ function buildTaskControlLines(taskId?: string, queuePaused = false, pauseReason
if (!taskId) return [`💡 发送 /tasks 查看实时任务状态`];
if (queuePaused) {
return [
`⏸️ **当前状态:已暂停**`,
`⏸️ **当前状态:全局下载队列已暂停**`,
pauseReason ? `📌 原因:${pauseReason}` : `📌 等待中的下载任务不会继续开始`,
`▶️ 点击下方“继续”可恢复后台下载队列`,
`🛑 点击“取消”可停止当前后台任务并清空等待项`,
`▶️ 点击下方“继续”会恢复全局下载队列`,
`🛑 点击“取消”只会结束这张后台任务卡;不会再误清空其它任务`,
];
}
return [
`💡 下载队列控制:请优先点下方按钮`,
`⏸ 暂停:暂时停止继续下载,已在处理的任务会尽快停住`,
`▶️ 继续:从暂停处恢复后台下载队列`,
`🛑 取消:停止当前后台任务并清空等待项`,
`💡 队列控制:按钮只对当前聊天的任务卡有效`,
`⏸ 暂停:暂停全局下载队列,已在处理的文件会尽快停住`,
`▶️ 继续:恢复全局下载队列`,
`🛑 取消:结束当前任务卡并移除按钮,不会误取消其它聊天任务`,
];
}
@@ -162,20 +162,16 @@ export function buildWelcomeBack(): string {
`您已通过验证,可以直接使用:`,
``,
`📤 发送/转发文件即可上传 (最大 2GB,账号级下载器不受此限制)`,
`🔐 /setup_2fa — 配置双重验证`,
`📥 /ytdlp — 解析并下载链接`,
`📁 /path_rules — 保存路径/自定义目录`,
`📡 /tg_sub — 订阅频道自动同步`,
`🗓️ /tg_date — 按日期下载频道文件`,
`🏷️ /tg_tag — 按标签下载频道文件`,
`🧾 /tg_jobs — Telegram 后台任务`,
`📊 /storage — 存储空间概览`,
`📋 /list — 最近上传记录`,
`🔧 /tasks — 实时任务队列`,
`🛑 /stop_tasks — 强制停止下载任务`,
`📦 /tg_download — 按日期/标签下载频道文件`,
`⚙️ /download_workers — 下载并发设置`,
`📁 /path_rules — 保存路径规则`,
`🧬 /duplicate_mode — 重复文件处理`,
`🧹 /cleanup_settings — 自动清理设置`,
`📊 /storage — 存储统计/清理本地文件`,
`🔧 /tasks — 实时任务队列`,
`🔐 /setup_2fa — 配置双重验证`,
`📥 /ytdlp — 解析并下载链接`,
`❓ /help — 完整帮助`,
].join('\n');
}
@@ -210,21 +206,20 @@ export function buildHelp(): string {
`**🛠 可用命令**`,
` /start — 身份认证 / 开始使用`,
` /setup\\_2fa — 配置双重验证 (TOTP)`,
` /ytdlp <url> — 下载视频链接到存储`,
` /path_rules — 保存路径/自定义目录面板`,
` /p <目录> — 下一次下载保存到指定目录`,
` /ps <目录> — 本会话持续保存到指定目录`,
` /pc — 清除自定义目录`,
` /tg_sub <频道> — 订阅频道新文件自动同步`,
` /tg_subs — 查看频道订阅`,
` /tg_unsub <频道或ID> — 取消订阅`,
` /tg_date <频道> <开始日期> <结束日期> — 按日期下载频道文件`,
` /tg_tag <频道> <#标签> — 下载频道内指定标签的全部文件`,
` /tg_jobs — 查看 Telegram 后台任务`,
` /storage — 服务器 & 存储统计`,
` /list [n] — 最近上传 (默认 10 条)`,
` /tasks — 实时传输任务队列`,
` /stop_tasks — 停止所有下载任务`,
` /tg_download — 按日期/标签下载频道文件`,
` /tg_download date <频道> <开始日期> <结束日期> — 按日期下载`,
` /tg_download tag <频道> <#标签> — 按标签下载`,
` /download_workers — 设置下载并发`,
` /path_rules — 设置保存路径规则`,
` /duplicate_mode — 设置重复文件处理`,
` /cleanup_settings — 设置自动清理开关`,
` /storage — 存储统计/清理本地文件`,
` /tasks — 实时传输任务队列`,
` /ytdlp <url> — 下载视频链接到存储`,
` /delete <ID或序号> — 删除指定文件`,
` /help — 显示此帮助`,
``,
@@ -253,6 +248,8 @@ interface StorageReportData {
diskUsedPercent: number;
fileCount: number;
totalFileSize: number;
localFileCount: number;
localTotalSize: number;
queueActive: number;
queuePending: number;
}
@@ -271,10 +268,15 @@ export function buildStorageReport(data: StorageReportData): string {
` 可 用 ${formatBytes(data.diskFree)}`,
` ${usageBar}`,
``,
`**📁 FlClouds 文件**`,
`**📁 存储源文件**`,
` 文件数 ${data.fileCount} 个`,
` 占 用 ${formatBytes(data.totalFileSize)}`,
``,
`**🖥️ 本地服务器下载文件**`,
` 文件数 ${data.localFileCount} 个`,
` 占 用 ${formatBytes(data.localTotalSize)}`,
` 位置 uploads 本地缓存/下载目录`,
``,
`**📡 下载队列**`,
` 🔄 处理中 ${data.queueActive} ⏳ 等待中 ${data.queuePending}`,
].join('\n');
+299
View File
@@ -0,0 +1,299 @@
import { Api } from 'telegram';
import { sanitizeFilename } from './telegramUtils.js';
import { getSetting, setSetting } from './settings.js';
interface ChatPathState {
nextFolder?: string;
sessionFolder?: string;
}
export type PendingPathInputMode = 'once' | 'session';
const chatPathState = new Map<string, ChatPathState>();
const pendingPathInputState = new Map<string, PendingPathInputMode>();
const recentPathState = new Map<string, string[]>();
const MAX_RECENT_PATHS = 6;
const RECENT_PATH_SETTING_PREFIX = 'telegram_recent_paths:';
function pendingPathInputKey(chatId: string, userId: number | string): string {
return `${chatId}:${userId}`;
}
function recentPathSettingKey(chatId: string): string {
return `${RECENT_PATH_SETTING_PREFIX}${chatId}`;
}
function normalizePathSegment(segment: string): string {
return sanitizeFilename(segment.trim()).replace(/^\.+/, '_').replace(/^\.+$/, '_');
}
function parseRecentPaths(raw: unknown): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(String(raw));
if (!Array.isArray(parsed)) return [];
return parsed
.filter(item => typeof item === 'string')
.map(item => item.trim())
.filter(Boolean)
.slice(0, MAX_RECENT_PATHS);
} catch {
return [];
}
}
async function loadRecentTelegramPaths(chatId: string): Promise<string[]> {
const cached = recentPathState.get(chatId);
if (cached) return [...cached];
const raw = await getSetting<string>(recentPathSettingKey(chatId), '[]');
const loaded = parseRecentPaths(raw);
recentPathState.set(chatId, loaded);
return [...loaded];
}
async function persistRecentTelegramPaths(chatId: string, paths: string[]): Promise<void> {
recentPathState.set(chatId, paths);
await setSetting(recentPathSettingKey(chatId), JSON.stringify(paths));
}
export function sanitizeCustomStoragePath(input: string): string {
const raw = input.trim().replace(/\\+/g, '/').replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '');
if (!raw) throw new Error('路径不能为空');
if (raw.startsWith('~') || raw.includes('\0')) throw new Error('路径包含非法字符');
const segments = raw.split('/').map(segment => segment.trim()).filter(Boolean);
if (segments.length === 0) throw new Error('路径不能为空');
if (segments.some(segment => segment === '.' || segment === '..' || segment.includes('..'))) {
throw new Error('路径不能包含 . 或 ..');
}
const normalized = segments.map(segment => normalizePathSegment(segment)).filter(Boolean).join('/');
if (!normalized) throw new Error('路径无效');
if (normalized.length > 180) throw new Error('路径过长,请控制在 180 个字符内');
return normalized;
}
export function rememberRecentTelegramPath(chatId: string, folder: string): string {
const normalized = sanitizeCustomStoragePath(folder);
const current = recentPathState.get(chatId) || [];
const next = [normalized, ...current.filter(item => item !== normalized)].slice(0, MAX_RECENT_PATHS);
recentPathState.set(chatId, next);
return normalized;
}
export async function rememberRecentTelegramPathPersistent(chatId: string, folder: string): Promise<string> {
const normalized = sanitizeCustomStoragePath(folder);
const current = await loadRecentTelegramPaths(chatId);
const next = [normalized, ...current.filter(item => item !== normalized)].slice(0, MAX_RECENT_PATHS);
await persistRecentTelegramPaths(chatId, next);
return normalized;
}
export function getRecentTelegramPaths(chatId: string): string[] {
return [...(recentPathState.get(chatId) || [])];
}
export async function getRecentTelegramPathsPersistent(chatId: string): Promise<string[]> {
return loadRecentTelegramPaths(chatId);
}
export function buildPathPreviewLine(folder: string): string {
return `保存到:${folder}/文件名(不会追加频道名或文件类型目录)`;
}
export function getTelegramPathState(chatId: string): ChatPathState {
return { ...(chatPathState.get(chatId) || {}) };
}
export function setNextTelegramPath(chatId: string, folder: string): string {
const normalized = rememberRecentTelegramPath(chatId, folder);
const state = chatPathState.get(chatId) || {};
state.nextFolder = normalized;
chatPathState.set(chatId, state);
return normalized;
}
export async function setNextTelegramPathPersistent(chatId: string, folder: string): Promise<string> {
const normalized = await rememberRecentTelegramPathPersistent(chatId, folder);
const state = chatPathState.get(chatId) || {};
state.nextFolder = normalized;
chatPathState.set(chatId, state);
return normalized;
}
export function setSessionTelegramPath(chatId: string, folder: string): string {
const normalized = rememberRecentTelegramPath(chatId, folder);
const state = chatPathState.get(chatId) || {};
state.sessionFolder = normalized;
chatPathState.set(chatId, state);
return normalized;
}
export async function setSessionTelegramPathPersistent(chatId: string, folder: string): Promise<string> {
const normalized = await rememberRecentTelegramPathPersistent(chatId, folder);
const state = chatPathState.get(chatId) || {};
state.sessionFolder = normalized;
chatPathState.set(chatId, state);
return normalized;
}
export function clearTelegramPathState(chatId: string): void {
chatPathState.delete(chatId);
}
export function setPendingTelegramPathInput(chatId: string, userId: number | string, mode: PendingPathInputMode): void {
pendingPathInputState.set(pendingPathInputKey(chatId, userId), mode);
}
export function getPendingTelegramPathInput(chatId: string, userId: number | string): PendingPathInputMode | undefined {
return pendingPathInputState.get(pendingPathInputKey(chatId, userId));
}
export function clearPendingTelegramPathInput(chatId: string, userId: number | string): void {
pendingPathInputState.delete(pendingPathInputKey(chatId, userId));
}
export function applyPendingTelegramPathInput(chatId: string, userId: number | string, folder: string): { mode: PendingPathInputMode; folder: string } | null {
const mode = getPendingTelegramPathInput(chatId, userId);
if (!mode) return null;
const normalized = mode === 'once'
? setNextTelegramPath(chatId, folder)
: setSessionTelegramPath(chatId, folder);
clearPendingTelegramPathInput(chatId, userId);
return { mode, folder: normalized };
}
export async function applyPendingTelegramPathInputPersistent(chatId: string, userId: number | string, folder: string): Promise<{ mode: PendingPathInputMode; folder: string } | null> {
const mode = getPendingTelegramPathInput(chatId, userId);
if (!mode) return null;
const normalized = mode === 'once'
? await setNextTelegramPathPersistent(chatId, folder)
: await setSessionTelegramPathPersistent(chatId, folder);
clearPendingTelegramPathInput(chatId, userId);
return { mode, folder: normalized };
}
export function buildPendingPathPrompt(mode: PendingPathInputMode, chatId?: string): string {
const recent = chatId ? getRecentTelegramPaths(chatId) : [];
return [
mode === 'once' ? '📌 **设置下一次下载目录**' : '📍 **设置会话下载目录**',
'',
'请直接发送目录名称:',
mode === 'once' ? '例如:`PIXIV/每日Top50`' : '例如:`相册/2026-07`',
...(recent.length > 0 ? ['', '最近使用目录:', ...recent.slice(0, 4).map(item => `- ${item}`)] : []),
'',
mode === 'once'
? '说明:只影响下一次进入下载流程的文件。'
: '说明:会影响当前聊天后续下载,直到发送 `/pc` 或点击清除。',
'发送“取消”可退出本次设置。',
].join('\n');
}
export async function buildPendingPathPromptPersistent(mode: PendingPathInputMode, chatId?: string): Promise<string> {
const recent = chatId ? await getRecentTelegramPathsPersistent(chatId) : [];
return [
mode === 'once' ? '📌 **设置下一次下载目录**' : '📍 **设置会话下载目录**',
'',
'请直接发送目录名称:',
mode === 'once' ? '例如:`PIXIV/每日Top50`' : '例如:`相册/2026-07`',
...(recent.length > 0 ? ['', '最近使用目录:', ...recent.slice(0, 4).map(item => `- ${item}`)] : []),
'',
mode === 'once'
? '说明:只影响下一次进入下载流程的文件。'
: '说明:会影响当前聊天后续下载,直到发送 `/pc` 或点击清除。',
'发送“取消”可退出本次设置。',
].join('\n');
}
export function resolveTelegramStorageFolder(chatId: string, automaticFolder: string | null | undefined): string | null {
const state = chatPathState.get(chatId);
if (!state) return automaticFolder || null;
if (state.nextFolder) {
const folder = state.nextFolder;
delete state.nextFolder;
if (!state.sessionFolder) chatPathState.delete(chatId);
return folder;
}
return state.sessionFolder || automaticFolder || null;
}
export function resolveTelegramBatchStorageFolder(chatId: string, automaticFolder: string | null | undefined): string | null {
// “下一次目录”应作用于下一次下载流程,而不是批量相册里的第一张图。
// 批量任务启动时消费一次 nextFolder,然后整批文件共用该目录。
return resolveTelegramStorageFolder(chatId, automaticFolder);
}
export function resolveTelegramTaskStorageFolder(chatId: string, automaticFolder: string | null | undefined): { folder: string | null; custom: boolean } {
const state = chatPathState.get(chatId);
if (!state) return { folder: automaticFolder || null, custom: false };
if (state.nextFolder) {
const folder = state.nextFolder;
delete state.nextFolder;
if (!state.sessionFolder) chatPathState.delete(chatId);
return { folder, custom: true };
}
if (state.sessionFolder) return { folder: state.sessionFolder, custom: true };
return { folder: automaticFolder || null, custom: false };
}
export function previewTelegramStorageFolder(chatId: string, automaticFolder: string | null | undefined): string | null {
const state = chatPathState.get(chatId);
return state?.nextFolder || state?.sessionFolder || automaticFolder || null;
}
export function buildTelegramPathStateLines(chatId: string): string[] {
const state = getTelegramPathState(chatId);
const active = state.nextFolder || state.sessionFolder;
return [
`当前保存:${active ? `\`${active}\`(自定义目录)` : '默认自动分类'}`,
active ? buildPathPreviewLine(active) : '默认示例:`telegram/资源下载/images`',
`📌 下一次目录:${state.nextFolder ? `\`${state.nextFolder}\`` : '未设置'}`,
`📍 本会话目录:${state.sessionFolder ? `\`${state.sessionFolder}\`` : '未设置'}`,
];
}
export interface PathCenterState { automaticBySource: boolean; automaticByType: boolean; }
export function buildPathSettingsKeyboard(_state: PathCenterState): Api.ReplyInlineMarkup {
return new Api.ReplyInlineMarkup({
rows: [
new Api.KeyboardButtonRow({
buttons: [
new Api.KeyboardButtonCallback({ text: '📌 设置下一次目录', data: Buffer.from('pr_help_once') }),
new Api.KeyboardButtonCallback({ text: '📍 设置会话目录', data: Buffer.from('pr_help_session') }),
],
}),
new Api.KeyboardButtonRow({
buttons: [
new Api.KeyboardButtonCallback({ text: '🕘 最近目录', data: Buffer.from('pr_recent') }),
new Api.KeyboardButtonCallback({ text: '🧹 清除自定义目录', data: Buffer.from('pr_clear_custom') }),
],
}),
],
});
}
export function buildPathSettingsText(
_state: PathCenterState,
chatId: string,
): string {
return [
'📁 **保存位置**',
'',
'**默认保存逻辑**',
'未设置自定义目录时:自动按来源/频道 + 文件类型保存。',
'例如:`telegram/资源下载/images`、`telegram/资源下载/videos`。',
'设置自定义目录后:文件会直接保存到该目录本身,不再追加频道名或文件类型目录。',
'',
'**当前路径状态**',
...buildTelegramPathStateLines(chatId),
'',
'**快捷命令**',
'`/p <目录>` — 仅下一次下载使用',
'`/ps <目录>` — 当前会话持续使用',
'`/pc` — 清除下一次/会话目录',
'',
'优先级:下一次目录 > 本会话目录 > 默认自动分类目录。',
'路径示例:设置 `/ps book` 后,文件直接保存到 `book`。',
].join('\n');
}
-17
View File
@@ -1,22 +1,5 @@
import crypto from 'crypto';
import path from 'path';
const ACCESS_PASSWORD_HASH = process.env.ACCESS_PASSWORD_HASH || '';
// Verify password using timing-safe comparison
export function verifyPassword(password: string): boolean {
if (!ACCESS_PASSWORD_HASH) {
return true;
}
const inputHash = crypto.createHash('sha256').update(password).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(inputHash), Buffer.from(ACCESS_PASSWORD_HASH));
} catch (e) {
// Length mismatch or other error
return false;
}
}
// Format bytes
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
+2
View File
@@ -28,6 +28,8 @@ services:
environment:
- DATABASE_URL=postgresql://flclouds:${DB_PASSWORD:?DB_PASSWORD is required}@postgres:5432/flclouds
- PORT=51947
- NODE_ENV=production
- COOKIE_SECURE=${COOKIE_SECURE:-true}
- UPLOAD_DIR=/data/uploads
- THUMBNAIL_DIR=/data/thumbnails
- CHUNK_DIR=/data/chunks
+9
View File
@@ -19,5 +19,14 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// Keep the lint gate practical for this codebase: TypeScript's build remains the
// strict correctness gate, while ESLint catches unused code and real footguns.
'@typescript-eslint/no-explicit-any': 'off',
'react-hooks/exhaustive-deps': 'off',
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
'react-refresh/only-export-components': 'off',
},
},
])
Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 230 KiB

+88 -53
View File
@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import { useState, useMemo, useEffect, useCallback, lazy, Suspense } from "react";
import { AppLayout } from "./components/layout/AppLayout";
import { Button } from "./components/ui/Button";
import { FileCard } from "./components/ui/FileCard";
@@ -6,11 +6,9 @@ import { FolderCard, type FolderData } from "./components/ui/FolderCard";
import { UploadZone } from "./components/ui/UploadZone";
import { Search, RefreshCw, ArrowLeft, ChevronDown, ChevronRight, CheckSquare, Cloud, HardDrive, Database, Package, Network, FolderPlus } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import { PreviewModal } from "./components/ui/PreviewModal";
import { BulkActionToolbar } from "./components/ui/BulkActionToolbar";
import { useTranslation } from "react-i18next";
import { EmptyState } from "./components/ui/EmptyState";
import { SettingsPage } from "./components/pages/SettingsPage";
import { LoginPage } from "./components/pages/LoginPage";
import { ViewToggle } from "./components/ui/ViewToggle";
import { FileMenu } from "./components/ui/FileMenu";
@@ -18,16 +16,27 @@ import { DeleteAlert } from "./components/ui/DeleteAlert";
import { FolderPromptModal } from "./components/ui/FolderPromptModal";
import { RenameModal } from "./components/ui/RenameModal";
import { MoveModal } from "./components/ui/MoveModal";
import { CreateFolderModal } from "./components/ui/CreateFolderModal";
import { UploadQueueModal, type QueueItem } from "./components/ui/UploadQueueModal";
import { Notification, type NotificationType } from "./components/ui/Notification";
import { fileApi, type FileData, type StorageStats as StorageStatsType } from "./services/api";
import { authService } from "./services/auth";
import type { QueueItem } from "./components/ui/UploadQueueModal";
const SettingsPage = lazy(() => import("./components/pages/SettingsPage").then(module => ({ default: module.SettingsPage })));
const PreviewModal = lazy(() => import("./components/ui/PreviewModal").then(module => ({ default: module.PreviewModal })));
const UploadQueueModal = lazy(() => import("./components/ui/UploadQueueModal").then(module => ({ default: module.UploadQueueModal })));
const CreateFolderModal = lazy(() => import("./components/ui/CreateFolderModal").then(module => ({ default: module.CreateFolderModal })));
const LazyFallback = () => (
<div className="flex min-h-32 items-center justify-center text-muted-foreground">
<RefreshCw className="h-5 w-5 animate-spin" />
</div>
);
function App() {
// 认证状态
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [needsPassword, setNeedsPassword] = useState(true);
const [setupRequired, setSetupRequired] = useState(false);
const [authChecking, setAuthChecking] = useState(true);
const [files, setFiles] = useState<FileData[]>([]);
@@ -105,12 +114,15 @@ function App() {
useEffect(() => {
const checkAuth = async () => {
try {
// 检查是否需要密码
const passwordRequired = await authService.checkPasswordRequired();
setNeedsPassword(passwordRequired);
// 检查认证/首次初始化状态
const authStatus = await authService.getAuthStatus();
setNeedsPassword(authStatus.passwordRequired);
setSetupRequired(authStatus.setupRequired);
if (!passwordRequired) {
// 不需要密码,直接进入
if (authStatus.setupRequired) {
setIsAuthenticated(false);
} else if (!authStatus.passwordRequired) {
// 不需要密码,直接进入(仅兼容历史/开发模式)
setIsAuthenticated(true);
} else if (authService.isAuthenticated()) {
// 已有 token,验证是否有效
@@ -211,6 +223,16 @@ function App() {
return result;
};
const handleInitialSetup = async (webPassword: string, telegramPin: string) => {
const result = await authService.setup(webPassword, telegramPin);
if (result.success) {
setSetupRequired(false);
setNeedsPassword(true);
setIsAuthenticated(true);
}
return result;
};
// 派生上传状态
const isUploading = useMemo(() => {
return uploadQueue.some(item => item.status === 'pending' || item.status === 'uploading');
@@ -354,46 +376,49 @@ function App() {
};
const handleConfirmDelete = async () => {
if (deletingFile) {
try {
await fileApi.deleteFile(deletingFile.id);
setFiles((prev) => prev.filter((f) => f.id !== deletingFile.id));
setDeletingFile(null);
// 刷新存储统计
loadStorageStats();
} catch (error: any) {
if (error.message === 'UNAUTHORIZED') {
authService.clearToken();
setIsAuthenticated(false);
} else {
console.error('删除失败:', error);
}
if (!deletingFile) return;
try {
await fileApi.deleteFile(deletingFile.id);
setFiles((prev) => prev.filter((f) => f.id !== deletingFile.id));
setDeletingFile(null);
setNotification({ show: true, message: '文件已删除', type: 'success' });
await loadStorageStats();
} catch (error: any) {
if (error.message === 'UNAUTHORIZED') {
authService.clearToken();
setIsAuthenticated(false);
} else {
console.error('删除失败:', error);
setNotification({ show: true, message: error.message || '删除失败', type: 'error' });
throw error;
}
}
};
const handleBatchDelete = async () => {
if (selectedFileIds.length === 0 && selectedFolderNames.length === 0) return;
const handleBatchDelete = async (fileIds = selectedFileIds, folderNames = selectedFolderNames) => {
if (fileIds.length === 0 && folderNames.length === 0) return;
// 简单确认
if (!confirm(`确定要删除选中的 ${selectedFileIds.length + selectedFolderNames.length} 个项目吗?`)) {
const itemCount = fileIds.length + folderNames.length;
const folderHint = folderNames.length > 0 ? `
包含 ${folderNames.length} 个文件夹,文件夹内文件也会删除。` : '';
if (!confirm(`确定要删除选中的 ${itemCount} 个项目吗?此操作无法撤销。${folderHint}`)) {
return;
}
try {
await fileApi.batchDelete(selectedFileIds, selectedFolderNames);
// 刷新列表和存储统计
const result = await fileApi.batchDelete(fileIds, folderNames);
await Promise.all([loadFiles(), loadStorageStats()]);
// 清空选择状态
setSelectedFileIds([]);
setSelectedFolderNames([]);
setIsSelectionMode(false);
setNotification({ show: true, message: result.message || '删除完成', type: 'success' });
} catch (error: any) {
if (error.message === 'UNAUTHORIZED') {
authService.clearToken();
setIsAuthenticated(false);
} else {
console.error('批量删除失败:', error);
setNotification({ show: true, message: error.message || '批量删除失败', type: 'error' });
}
} finally {
setLoading(false);
@@ -696,7 +721,7 @@ function App() {
// 需要密码但未认证,显示登录页
if (needsPassword && !isAuthenticated) {
return <LoginPage onLogin={handleLogin} />;
return <LoginPage onLogin={handleLogin} setupRequired={setupRequired} onSetup={handleInitialSetup} />;
}
return (
@@ -706,7 +731,9 @@ function App() {
{/* Main Content Area */}
{currentCategory === "settings" ? (
<SettingsPage storageStats={storageStats} />
<Suspense fallback={<LazyFallback />}>
<SettingsPage storageStats={storageStats} />
</Suspense>
) : (
<>
{/* Header Actions */}
@@ -952,11 +979,7 @@ function App() {
onRename={() => setRenamingFolder(folder.name)}
onToggleFavorite={() => handleToggleFolderFavorite(folder.name)}
onMove={() => setMovingFolder(folder.name)}
onDelete={() => {
setSelectedFolderNames([folder.name]);
setSelectedFileIds([]);
handleBatchDelete();
}}
onDelete={() => handleBatchDelete([], [folder.name])}
isSelectionMode={isSelectionMode}
isSelected={selectedFolderNames.includes(folder.name)}
onSelect={toggleFolderSelection}
@@ -1041,19 +1064,27 @@ function App() {
)}
</div>
<PreviewModal
file={selectedFile}
onClose={() => setSelectedFile(null)}
onToggleFavorite={handleToggleFavorite}
/>
<Suspense fallback={null}>
{selectedFile && (
<PreviewModal
file={selectedFile}
onClose={() => setSelectedFile(null)}
onToggleFavorite={handleToggleFavorite}
/>
)}
</Suspense>
{/* 这里的 isOpen 逻辑是:如果有正在上传的,或者用户没点关闭(且有内容),就显示? */}
{/* 现在的逻辑是:多文件触发 setIsQueueModalOpen(true),关闭则 false。 */}
<UploadQueueModal
isOpen={isQueueModalOpen}
onClose={handleCloseQueue}
items={uploadQueue}
/>
<Suspense fallback={null}>
{isQueueModalOpen && (
<UploadQueueModal
isOpen={isQueueModalOpen}
onClose={handleCloseQueue}
items={uploadQueue}
/>
)}
</Suspense>
<DeleteAlert
isOpen={!!deletingFile}
@@ -1085,11 +1116,15 @@ function App() {
onCancel={() => startUpload(pendingFiles)}
/>
<CreateFolderModal
isOpen={isCreateFolderModalOpen}
onClose={() => setIsCreateFolderModalOpen(false)}
onConfirm={handleCreateFolder}
/>
<Suspense fallback={null}>
{isCreateFolderModalOpen && (
<CreateFolderModal
isOpen={isCreateFolderModalOpen}
onClose={() => setIsCreateFolderModalOpen(false)}
onConfirm={handleCreateFolder}
/>
)}
</Suspense>
<MoveModal
isOpen={!!movingFile || !!movingFolder}
+85 -8
View File
@@ -5,10 +5,14 @@ import { authService } from '../../services/auth';
interface LoginPageProps {
onLogin: (password: string) => Promise<{ success: boolean; error?: string; requiresTOTP?: boolean }>;
setupRequired?: boolean;
onSetup?: (webPassword: string, telegramPin: string) => Promise<{ success: boolean; error?: string }>;
}
export const LoginPage = ({ onLogin }: LoginPageProps) => {
export const LoginPage = ({ onLogin, setupRequired = false, onSetup }: LoginPageProps) => {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [telegramPin, setTelegramPin] = useState('');
const [totpToken, setTotpToken] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
@@ -18,6 +22,42 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
const handlePasswordSubmit = async (e: FormEvent) => {
e.preventDefault();
if (setupRequired) {
if (!password || password.length < 8) {
setError('网页管理员密码至少需要 8 位');
return;
}
if (password !== confirmPassword) {
setError('两次输入的网页密码不一致');
return;
}
if (!/^\d{4}$/.test(telegramPin)) {
setError('Telegram Bot 密码必须是 4 位数字');
return;
}
if (password === telegramPin) {
setError('网页密码不能与 Telegram Bot 4 位密码相同');
return;
}
if (!onSetup) {
setError('初始化接口未就绪');
return;
}
setLoading(true);
setError('');
try {
const result = await onSetup(password, telegramPin);
if (!result.success) {
setError(result.error || '初始化失败');
setLoading(false);
}
} catch {
setError('初始化请求失败');
setLoading(false);
}
return;
}
if (!password.trim()) {
setError('请输入密码');
return;
@@ -37,7 +77,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
setLoading(false);
}
// 如果成功且不需要 TOTP,App.tsx 会处理状态跳转
} catch (err) {
} catch {
setError('登录请求失败');
setLoading(false);
}
@@ -64,7 +104,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
// 验证成功,页面会自动因为认证状态改变而卸载
window.location.reload(); // 简单处理,或者在 App.tsx 中通过状态流转
}
} catch (err) {
} catch {
setError('验证请求失败');
setLoading(false);
}
@@ -94,7 +134,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
</motion.div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">FlClouds</h1>
<p className="text-muted-foreground mt-1">
{step === 'password' ? '请输入访问密码' : '双重身份验证'}
{setupRequired ? '首次启动,请创建唯一管理员密码' : (step === 'password' ? '请输入访问密码' : '双重身份验证')}
</p>
</div>
@@ -123,7 +163,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
{/* Password Input */}
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-foreground">
访问密码
{setupRequired ? '网页管理员密码' : '访问密码'}
</label>
<div className="relative">
<input
@@ -131,7 +171,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
placeholder={setupRequired ? '至少 8 位,建议使用强密码' : '请输入密码'}
className="w-full h-12 px-4 pr-12 rounded-xl border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
autoFocus
disabled={loading}
@@ -146,6 +186,43 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
</div>
</div>
{setupRequired && (
<div className="space-y-4 mt-4">
<div className="space-y-2">
<label htmlFor="confirm-password" className="text-sm font-medium text-foreground">
确认网页密码
</label>
<input
id="confirm-password"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再次输入网页管理员密码"
className="w-full h-12 px-4 rounded-xl border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
disabled={loading}
/>
</div>
<div className="space-y-2">
<label htmlFor="telegram-pin" className="text-sm font-medium text-foreground">
Telegram Bot 密码(4 位数字)
</label>
<input
id="telegram-pin"
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={telegramPin}
onChange={(e) => setTelegramPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="0000"
className="w-full h-12 px-4 rounded-xl border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
disabled={loading}
/>
<p className="text-xs text-muted-foreground">首次创建后会二次加密存入数据库,不再允许无密码访问。</p>
</div>
</div>
)}
<motion.button
type="submit"
disabled={loading}
@@ -158,7 +235,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
) : (
<>
<LogIn className="w-5 h-5" />
<span>下一步</span>
<span>{setupRequired ? '创建管理员并进入' : '下一步'}</span>
</>
)}
</motion.button>
@@ -236,7 +313,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
{/* Footer */}
<p className="text-center text-xs text-muted-foreground mt-6">
登录状态将保留 7 天
{setupRequired ? '生产环境首次访问必须先创建网页密码和 Telegram Bot 4 位密码' : '登录状态将保留 7 天'}
</p>
</motion.div>
</div>
@@ -273,7 +273,7 @@ export const SettingsPage = ({ storageStats }: SettingsPageProps) => {
};
const handleDeleteAccount = async (accountId: string, accountName: string) => {
if (!window.confirm(`确定要删除账户 "${accountName}" 吗?\n\n该账户中已上传的文件记录会保留,但将不再关联到任何账户。`)) return;
if (!window.confirm(`确定要删除账户 "${accountName}" 吗?\n\n将删除该账户在 FlClouds 中的文件索引记录,但不会删除云端原文件。`)) return;
try {
const result = await fileApi.deleteAccount(accountId);
alert(result.message);
@@ -32,8 +32,8 @@ export const BulkActionToolbar = ({
const [generatedLink, setGeneratedLink] = useState<string | null>(null);
// Share is only available when exactly one item is selected
const canShare = selectedFilesCount + selectedFoldersCount === 1;
// Share is currently only available for exactly one file (not folders).
const canShare = selectedFilesCount === 1 && selectedFoldersCount === 0;
const handleShareClick = () => {
if (showShareSettings) {
+19 -8
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -7,15 +8,27 @@ import { createPortal } from "react-dom";
interface DeleteAlertProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
onConfirm: () => void | Promise<void>;
fileName?: string;
}
export const DeleteAlert = ({ isOpen, onClose, onConfirm, fileName }: DeleteAlertProps) => {
const { t } = useTranslation();
const [isDeleting, setIsDeleting] = useState(false);
if (!isOpen) return null;
const handleConfirm = async () => {
if (isDeleting) return;
setIsDeleting(true);
try {
await onConfirm();
onClose();
} finally {
setIsDeleting(false);
}
};
const modalContent = (
<AnimatePresence>
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
@@ -26,7 +39,7 @@ export const DeleteAlert = ({ isOpen, onClose, onConfirm, fileName }: DeleteAler
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
onClick={isDeleting ? undefined : onClose}
/>
{/* Modal Container */}
@@ -77,18 +90,16 @@ export const DeleteAlert = ({ isOpen, onClose, onConfirm, fileName }: DeleteAler
<Button
variant="outline"
className="h-10 px-5 text-sm font-medium border-border/80 hover:bg-muted"
onClick={onClose}
onClick={isDeleting ? undefined : onClose}
>
{t("delete.cancel") || "取消"}
</Button>
<Button
className="h-10 px-5 text-sm font-medium bg-red-600 hover:bg-red-700 text-white shadow-sm border border-red-700/50"
onClick={() => {
onConfirm();
onClose();
}}
onClick={handleConfirm}
disabled={isDeleting}
>
{t("delete.confirm") || "确认删除"}
{isDeleting ? "删除中..." : (t("delete.confirm") || "确认删除")}
</Button>
</div>
</motion.div>
+2 -1
View File
@@ -50,8 +50,9 @@ export const FileCard = ({
e?.stopPropagation();
try {
await fileApi.downloadFile(file.id, file.name);
} catch (error) {
} catch (error: any) {
console.error("下载失败", error);
window.alert(error?.message || "下载失败");
}
};
+1 -1
View File
@@ -53,7 +53,7 @@ export const RenameModal = ({ isOpen, onClose, onConfirm, currentName, type }: R
setError(type === "file" ? "文件名不能为空" : "文件夹名不能为空");
return;
}
if (/[\/\\:*?"<>|]/.test(trimmed)) {
if (/[\\:*?"<>|/]/.test(trimmed)) {
setError("名称包含非法字符");
return;
}
+7 -7
View File
@@ -140,12 +140,6 @@ class FileAPI {
xhr.open('POST', `${API_BASE}/api/upload`);
xhr.withCredentials = true;
// 添加认证头
const token = authService.getToken();
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
xhr.send(formData);
});
}
@@ -182,6 +176,7 @@ class FileAPI {
const chunk = file.slice(start, end);
const chunkResponse = await fetch(`${API_BASE}/api/chunked/chunk`, {
credentials: 'include',
method: 'POST',
headers: getHeaders({
'Content-Type': 'application/octet-stream',
@@ -220,6 +215,7 @@ class FileAPI {
} catch (error) {
try {
await fetch(`${API_BASE}/api/chunked/${uploadId}`, {
credentials: 'include',
method: 'DELETE',
headers: getHeaders(),
});
@@ -266,7 +262,11 @@ class FileAPI {
headers: getHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ fileIds, folderNames }),
});
if (response.status === 401) throw new Error('UNAUTHORIZED');
if (response.status === 401 || response.status === 428) throw new Error('UNAUTHORIZED');
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || error.message || '批量删除失败');
}
return response.json();
}
+54 -45
View File
@@ -1,67 +1,78 @@
import { API_BASE } from './config';
const TOKEN_KEY = 'flclouds_token';
const TOKEN_EXPIRY_KEY = 'flclouds_token_expiry';
export interface AuthStatus {
setupRequired: boolean;
passwordRequired: boolean;
}
class AuthService {
private token: string | null = null;
constructor() {
// 从 localStorage 恢复 token
this.token = localStorage.getItem(TOKEN_KEY);
const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
// 检查是否过期
if (expiry && new Date() > new Date(expiry)) {
this.clearToken();
}
// Cookie-only auth: clear legacy localStorage tokens if present.
this.clearToken();
}
getToken(): string | null {
return this.token;
return null;
}
setToken(token: string, expiresAt: string) {
this.token = token;
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt);
setToken() {
// Authentication is stored only in the HttpOnly flclouds_token cookie.
}
clearToken() {
this.token = null;
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRY_KEY);
localStorage.removeItem('flclouds_token');
localStorage.removeItem('flclouds_token_expiry');
}
isAuthenticated(): boolean {
if (!this.token) return false;
const expiry = localStorage.getItem(TOKEN_EXPIRY_KEY);
if (expiry && new Date() > new Date(expiry)) {
this.clearToken();
return false;
}
// The backend /verify endpoint is the source of truth for the HttpOnly cookie.
return true;
}
// 获取认证头
getAuthHeaders(): HeadersInit {
if (this.token) {
return { 'Authorization': `Bearer ${this.token}` };
}
return {};
}
// 检查是否需要密码
async checkPasswordRequired(): Promise<boolean> {
// 获取认证初始化状态
async getAuthStatus(): Promise<AuthStatus> {
try {
const response = await fetch(`${API_BASE}/api/auth/status`);
if (!response.ok) return true;
if (!response.ok) return { setupRequired: false, passwordRequired: true };
const data = await response.json();
return data.passwordRequired !== false;
return {
setupRequired: data.setupRequired === true,
passwordRequired: data.passwordRequired !== false,
};
} catch {
return true;
return { setupRequired: false, passwordRequired: true };
}
}
// 兼容旧调用:检查是否需要密码
async checkPasswordRequired(): Promise<boolean> {
const status = await this.getAuthStatus();
return status.passwordRequired;
}
// 首次启动创建唯一管理员凭证
async setup(webPassword: string, telegramPin: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${API_BASE}/api/auth/setup`, {
credentials: 'include',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ webPassword, telegramPin }),
});
const data = await response.json();
if (!response.ok) {
return { success: false, error: data.error || '初始化失败' };
}
this.setToken();
return { success: true };
} catch {
return { success: false, error: '网络错误' };
}
}
@@ -85,9 +96,9 @@ class AuthService {
return { success: true, requiresTOTP: true };
}
this.setToken(data.token, data.expiresAt);
this.setToken();
return { success: true };
} catch (error) {
} catch {
return { success: false, error: '网络错误' };
}
}
@@ -108,17 +119,15 @@ class AuthService {
return { success: false, error: data.error || '验证失败' };
}
this.setToken(data.token, data.expiresAt);
this.setToken();
return { success: true };
} catch (error) {
} catch {
return { success: false, error: '网络错误' };
}
}
// 验证 Token
async verify(): Promise<boolean> {
if (!this.token) return false;
try {
const response = await fetch(`${API_BASE}/api/auth/verify`, {
credentials: 'include',
@@ -188,7 +197,7 @@ class AuthService {
}
return { success: true };
} catch (error) {
} catch {
return { success: false, error: '网络错误' };
}
}
@@ -212,7 +221,7 @@ class AuthService {
}
return { success: true };
} catch (error) {
} catch {
return { success: false, error: '网络错误' };
}
}
+12
View File
@@ -4,4 +4,16 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
motion: ['framer-motion'],
i18n: ['i18next', 'react-i18next', 'i18next-browser-languagedetector'],
icons: ['lucide-react'],
},
},
},
},
})