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:
1 parent
fd2faa97e7
commit
71d2de6dbe
38 files changed
+4452
-2037
No files matched your search
@@ -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 容器化部署
|
||||
|
||||
|
||||
Vendored
+2150
-1173
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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: '删除账户失败' });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+554
-156
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 || '未知';
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '网络错误' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user