From 71d2de6dbe9a3b99e82c7ad93c57c60eb08beac2 Mon Sep 17 00:00:00 2001 From: hicocos Date: Thu, 2 Jul 2026 15:17:30 +0800 Subject: [PATCH] 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 --- README.md | 42 +- backend/dist/index.js | 3323 +++++++++++------ backend/src/db/schema.sql | 4 +- backend/src/index.ts | 16 +- backend/src/routes/auth.ts | 118 +- backend/src/routes/chunkedUpload.ts | 52 +- backend/src/routes/files.ts | 298 +- backend/src/routes/storage.ts | 36 +- backend/src/routes/upload.ts | 3 +- backend/src/services/storage.ts | 19 +- backend/src/services/telegramBot.ts | 710 +++- backend/src/services/telegramChannelJobs.ts | 88 +- backend/src/services/telegramCommands.ts | 462 ++- backend/src/services/telegramState.ts | 37 +- backend/src/services/telegramUpload.ts | 259 +- backend/src/services/ytDlpDownload.ts | 11 +- backend/src/utils/authSettings.ts | 121 + backend/src/utils/credentialCrypto.ts | 2 + backend/src/utils/fileScope.ts | 63 + backend/src/utils/networkSecurity.ts | 16 + backend/src/utils/security.ts | 16 +- backend/src/utils/storagePath.ts | 9 +- backend/src/utils/telegramMessages.ts | 60 +- backend/src/utils/telegramPathSettings.ts | 299 ++ backend/src/utils/telegramUtils.ts | 17 - docker-compose.yml | 2 + frontend/eslint.config.js | 9 + frontend/public/logo.png | Bin 274665 -> 235805 bytes frontend/src/App.tsx | 141 +- frontend/src/components/pages/LoginPage.tsx | 93 +- .../src/components/pages/SettingsPage.tsx | 2 +- .../src/components/ui/BulkActionToolbar.tsx | 4 +- frontend/src/components/ui/DeleteAlert.tsx | 27 +- frontend/src/components/ui/FileCard.tsx | 3 +- frontend/src/components/ui/RenameModal.tsx | 2 +- frontend/src/services/api.ts | 14 +- frontend/src/services/auth.ts | 99 +- frontend/vite.config.ts | 12 + 38 files changed, 4452 insertions(+), 2037 deletions(-) create mode 100644 backend/src/utils/authSettings.ts create mode 100644 backend/src/utils/fileScope.ts create mode 100644 backend/src/utils/telegramPathSettings.ts diff --git a/README.md b/README.md index 483da33..3dea131 100644 --- a/README.md +++ b/README.md @@ -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 容器化部署 diff --git a/backend/dist/index.js b/backend/dist/index.js index 691cf72..6a8c8b0 100644 --- a/backend/dist/index.js +++ b/backend/dist/index.js @@ -280,7 +280,9 @@ var init_credentialCrypto = __esm({ "onedrive_client_secret", "onedrive_refresh_token", "google_drive_client_secret", - "google_drive_refresh_token" + "google_drive_refresh_token", + "admin_password_hash", + "telegram_pin_hash" ]); } }); @@ -633,7 +635,7 @@ var init_storage = __esm({ } }; OneDriveStorageProvider = class { - // 存储文件夹名 + // 使用第三方存储根目录,不再额外套 FlClouds 目录 constructor(id, clientId, clientSecret, refreshToken, tenantId = "common") { this.id = id; this.clientId = clientId; @@ -650,7 +652,7 @@ var init_storage = __esm({ name = "onedrive"; accessToken = null; tokenExpiresAt = 0; - ONEDRIVE_FOLDER = "FlClouds"; + ONEDRIVE_FOLDER = ""; /** * 生成 OAuth 授权 URL */ @@ -737,6 +739,7 @@ var init_storage = __esm({ */ async ensureFolderExists(token, folder) { 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)}:`; await axios.get(endpoint, { @@ -790,13 +793,13 @@ var init_storage = __esm({ const fileSize = stats.size; console.log(`[OneDrive] Uploading file: ${fileName}, size: ${fileSize} bytes, type: ${mimeType}`); const uploadFolder = await this.ensureFolderExists(token, folder); - const encodedFileName = encodeURIComponent(fileName); + const targetPath = uploadFolder ? `${uploadFolder}/${fileName}` : fileName; try { if (fileSize < 4 * 1024 * 1024) { console.log("[OneDrive] Using simple upload for small file"); const fileBuffer = await fs4.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: { @@ -814,7 +817,7 @@ var init_storage = __esm({ } else { console.log("[OneDrive] Using chunked upload session for large file"); 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", @@ -872,7 +875,7 @@ var init_storage = __esm({ return lastResponse.data.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: 3e4 @@ -1457,8 +1460,12 @@ var init_storage = __esm({ ); } getProvider(name) { - 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; } @@ -1599,14 +1606,14 @@ var init_storage = __esm({ import express from "express"; import cors from "cors"; import dotenv3 from "dotenv"; -import path19 from "path"; -import fs15 from "fs"; +import path21 from "path"; +import fs16 from "fs"; // src/routes/files.ts init_db(); import { Router as Router2 } from "express"; -import fs11 from "fs"; -import path14 from "path"; +import fs12 from "fs"; +import path16 from "path"; // src/middleware/signedUrl.ts import crypto8 from "crypto"; @@ -1754,14 +1761,7 @@ async function generateOTPAuthUrl(user = "Admin") { return await QRCode.toDataURL(otpauth); } function getClientIP(req) { - 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(); - } - return req.ip || "\u672A\u77E5"; + return req.ip || req.socket?.remoteAddress || "\u672A\u77E5"; } // src/routes/auth.ts @@ -1770,24 +1770,129 @@ import axios2 from "axios"; // src/services/telegramBot.ts init_storage(); -import { TelegramClient as TelegramClient4, Api as Api4 } from "telegram"; +import { TelegramClient as TelegramClient4, Api as Api5 } from "telegram"; import { StringSession as StringSession2 } from "telegram/sessions/index.js"; import { NewMessage } from "telegram/events/index.js"; import { Raw } from "telegram/events/index.js"; -import fs10 from "fs"; -import path13 from "path"; +import fs11 from "fs"; +import path15 from "path"; // src/services/telegramState.ts init_db(); + +// src/utils/authSettings.ts +import crypto4 from "crypto"; +var WEB_PASSWORD_KEY = "admin_password_hash"; +var TELEGRAM_PIN_KEY = "telegram_pin_hash"; +var TELEGRAM_ALLOWED_USERS_KEY = "telegram_allowed_user_ids"; +var SCRYPT_PREFIX = "scrypt:v1"; +function hashSecret(secret) { + const salt = crypto4.randomBytes(16).toString("base64url"); + const derived = crypto4.scryptSync(secret, salt, 64).toString("base64url"); + return `${SCRYPT_PREFIX}:${salt}:${derived}`; +} +function safeEqualText(a, b) { + try { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && crypto4.timingSafeEqual(left, right); + } catch { + return false; + } +} +function verifySecret(secret, stored) { + if (!stored) return false; + if (stored.startsWith(`${SCRYPT_PREFIX}:`)) { + const [, , salt, expected] = stored.split(":"); + if (!salt || !expected) return false; + const actual = crypto4.scryptSync(secret, salt, 64).toString("base64url"); + return safeEqualText(actual, expected); + } + if (/^[a-f0-9]{64}$/i.test(stored)) { + const actual = crypto4.createHash("sha256").update(secret).digest("hex"); + return safeEqualText(actual, stored.toLowerCase()); + } + return false; +} +async function getStoredWebPasswordHash() { + const stored = await getSetting(WEB_PASSWORD_KEY, ""); + return stored || process.env.ACCESS_PASSWORD_HASH || ""; +} +async function isInitialSetupRequired() { + return !await getStoredWebPasswordHash(); +} +async function verifyWebPassword(password) { + return verifySecret(password, await getStoredWebPasswordHash()); +} +async function verifyTelegramPin(pin) { + const stored = await getSetting(TELEGRAM_PIN_KEY, ""); + if (stored) return verifySecret(pin, stored); + return verifySecret(pin, await getStoredWebPasswordHash()); +} +function validateWebPassword(password) { + if (typeof password !== "string" || password.length < 8) { + return "\u7F51\u9875\u7BA1\u7406\u5458\u5BC6\u7801\u81F3\u5C11\u9700\u8981 8 \u4F4D"; + } + if (password.length > 256) { + return "\u7F51\u9875\u7BA1\u7406\u5458\u5BC6\u7801\u8FC7\u957F"; + } + return null; +} +function validateTelegramPin(pin) { + if (typeof pin !== "string" || !/^\d{4}$/.test(pin)) { + return "Telegram Bot \u5BC6\u7801\u5FC5\u987B\u662F 4 \u4F4D\u6570\u5B57"; + } + return null; +} +async function createInitialAdminCredentials(webPassword, telegramPin) { + if (!await isInitialSetupRequired()) { + throw new Error("\u7BA1\u7406\u5458\u5BC6\u7801\u5DF2\u521B\u5EFA\uFF0C\u4E0D\u80FD\u91CD\u590D\u521D\u59CB\u5316"); + } + 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("\u7F51\u9875\u5BC6\u7801\u4E0D\u80FD\u4E0E Telegram Bot 4 \u4F4D\u5BC6\u7801\u76F8\u540C"); + } + await setSetting(WEB_PASSWORD_KEY, hashSecret(webPassword)); + await setSetting(TELEGRAM_PIN_KEY, hashSecret(telegramPin)); +} +function parseUserIds(value) { + if (!value) return []; + return [...new Set(String(value).split(",").map((item) => Number(item.trim())).filter((item) => Number.isSafeInteger(item) && item > 0))]; +} +async function getConfiguredTelegramAllowedUsers() { + const envUsers = parseUserIds(process.env.TELEGRAM_ALLOWED_USER_IDS || ""); + if (envUsers.length > 0) return envUsers; + const stored = await getSetting(TELEGRAM_ALLOWED_USERS_KEY, ""); + return parseUserIds(stored || ""); +} + +// src/services/telegramState.ts var userStates = /* @__PURE__ */ new Map(); var authenticatedUsers = /* @__PURE__ */ new Map(); var passwordInputState = /* @__PURE__ */ new Map(); +async function revokeAuthenticatedUser(userId) { + authenticatedUsers.delete(userId); + try { + await query("DELETE FROM telegram_auth WHERE user_id = $1", [userId]); + } catch (error) { + console.error("\u{1F916} \u64A4\u9500 Telegram \u6388\u6743\u7528\u6237\u5931\u8D25:", error); + } +} async function loadAuthenticatedUsers() { try { + const allowedUsers = await getConfiguredTelegramAllowedUsers(); const result = await query("SELECT user_id, authenticated_at FROM telegram_auth"); - result.rows.forEach((row) => { - 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(`\u{1F916} \u5DF2\u4ECE\u6570\u636E\u5E93\u8F7D\u5165 ${authenticatedUsers.size} \u4E2A\u6388\u6743\u7528\u6237`); } catch (error) { console.error("\u{1F916} \u8F7D\u5165\u5DF2\u9A8C\u8BC1\u7528\u6237\u5931\u8D25:", error); @@ -1802,38 +1907,25 @@ async function persistAuthenticatedUser(userId) { console.error("\u{1F916} \u6301\u4E45\u5316\u7528\u6237\u5931\u8D25:", error); } } -function isAuthenticated(userId) { - const ACCESS_PASSWORD_HASH3 = process.env.ACCESS_PASSWORD_HASH || ""; - if (!ACCESS_PASSWORD_HASH3) { - return true; +async function isAuthenticatedAsync(userId) { + const allowedUsers = await getConfiguredTelegramAllowedUsers(); + if (allowedUsers.length > 0 && !allowedUsers.includes(userId)) { + if (authenticatedUsers.has(userId)) await revokeAuthenticatedUser(userId); + return false; } return authenticatedUsers.has(userId); } // src/services/telegramCommands.ts init_db(); -import { Api as Api3 } from "telegram"; +import { Api as Api4 } from "telegram"; import checkDiskSpaceModule from "check-disk-space"; import os from "os"; - -// src/utils/telegramMessages.ts -import { Api } from "telegram"; +import fs9 from "fs"; +import path13 from "path"; // src/utils/telegramUtils.ts -import crypto4 from "crypto"; import path5 from "path"; -var ACCESS_PASSWORD_HASH2 = process.env.ACCESS_PASSWORD_HASH || ""; -function verifyPassword(password) { - if (!ACCESS_PASSWORD_HASH2) { - return true; - } - const inputHash = crypto4.createHash("sha256").update(password).digest("hex"); - try { - return crypto4.timingSafeEqual(Buffer.from(inputHash), Buffer.from(ACCESS_PASSWORD_HASH2)); - } catch (e) { - return false; - } -} function formatBytes(bytes) { if (bytes === 0) return "0 B"; const k = 1024; @@ -1947,6 +2039,7 @@ function sanitizeFilename(name) { } // src/utils/telegramMessages.ts +import { Api } from "telegram"; var PROVIDER_DISPLAY_MAP = { onedrive: "\u2601\uFE0F OneDrive", aliyun_oss: "\u2601\uFE0F \u963F\u91CC\u4E91 OSS", @@ -1962,17 +2055,17 @@ function buildTaskControlLines(taskId, queuePaused = false, pauseReason) { if (!taskId) return [`\u{1F4A1} \u53D1\u9001 /tasks \u67E5\u770B\u5B9E\u65F6\u4EFB\u52A1\u72B6\u6001`]; if (queuePaused) { return [ - `\u23F8\uFE0F **\u5F53\u524D\u72B6\u6001\uFF1A\u5DF2\u6682\u505C**`, + `\u23F8\uFE0F **\u5F53\u524D\u72B6\u6001\uFF1A\u5168\u5C40\u4E0B\u8F7D\u961F\u5217\u5DF2\u6682\u505C**`, pauseReason ? `\u{1F4CC} \u539F\u56E0\uFF1A${pauseReason}` : `\u{1F4CC} \u7B49\u5F85\u4E2D\u7684\u4E0B\u8F7D\u4EFB\u52A1\u4E0D\u4F1A\u7EE7\u7EED\u5F00\u59CB`, - `\u25B6\uFE0F \u70B9\u51FB\u4E0B\u65B9\u201C\u7EE7\u7EED\u201D\u53EF\u6062\u590D\u540E\u53F0\u4E0B\u8F7D\u961F\u5217`, - `\u{1F6D1} \u70B9\u51FB\u201C\u53D6\u6D88\u201D\u53EF\u505C\u6B62\u5F53\u524D\u540E\u53F0\u4EFB\u52A1\u5E76\u6E05\u7A7A\u7B49\u5F85\u9879` + `\u25B6\uFE0F \u70B9\u51FB\u4E0B\u65B9\u201C\u7EE7\u7EED\u201D\u4F1A\u6062\u590D\u5168\u5C40\u4E0B\u8F7D\u961F\u5217`, + `\u{1F6D1} \u70B9\u51FB\u201C\u53D6\u6D88\u201D\u53EA\u4F1A\u7ED3\u675F\u8FD9\u5F20\u540E\u53F0\u4EFB\u52A1\u5361\uFF1B\u4E0D\u4F1A\u518D\u8BEF\u6E05\u7A7A\u5176\u5B83\u4EFB\u52A1` ]; } return [ - `\u{1F4A1} \u4E0B\u8F7D\u961F\u5217\u63A7\u5236\uFF1A\u8BF7\u4F18\u5148\u70B9\u4E0B\u65B9\u6309\u94AE`, - `\u23F8 \u6682\u505C\uFF1A\u6682\u65F6\u505C\u6B62\u7EE7\u7EED\u4E0B\u8F7D\uFF0C\u5DF2\u5728\u5904\u7406\u7684\u4EFB\u52A1\u4F1A\u5C3D\u5FEB\u505C\u4F4F`, - `\u25B6\uFE0F \u7EE7\u7EED\uFF1A\u4ECE\u6682\u505C\u5904\u6062\u590D\u540E\u53F0\u4E0B\u8F7D\u961F\u5217`, - `\u{1F6D1} \u53D6\u6D88\uFF1A\u505C\u6B62\u5F53\u524D\u540E\u53F0\u4EFB\u52A1\u5E76\u6E05\u7A7A\u7B49\u5F85\u9879` + `\u{1F4A1} \u961F\u5217\u63A7\u5236\uFF1A\u6309\u94AE\u53EA\u5BF9\u5F53\u524D\u804A\u5929\u7684\u4EFB\u52A1\u5361\u6709\u6548`, + `\u23F8 \u6682\u505C\uFF1A\u6682\u505C\u5168\u5C40\u4E0B\u8F7D\u961F\u5217\uFF0C\u5DF2\u5728\u5904\u7406\u7684\u6587\u4EF6\u4F1A\u5C3D\u5FEB\u505C\u4F4F`, + `\u25B6\uFE0F \u7EE7\u7EED\uFF1A\u6062\u590D\u5168\u5C40\u4E0B\u8F7D\u961F\u5217`, + `\u{1F6D1} \u53D6\u6D88\uFF1A\u7ED3\u675F\u5F53\u524D\u4EFB\u52A1\u5361\u5E76\u79FB\u9664\u6309\u94AE\uFF0C\u4E0D\u4F1A\u8BEF\u53D6\u6D88\u5176\u5B83\u804A\u5929\u4EFB\u52A1` ]; } function buildTaskControlButtons(taskId) { @@ -2063,20 +2156,16 @@ function buildWelcomeBack() { `\u60A8\u5DF2\u901A\u8FC7\u9A8C\u8BC1\uFF0C\u53EF\u4EE5\u76F4\u63A5\u4F7F\u7528\uFF1A`, ``, `\u{1F4E4} \u53D1\u9001/\u8F6C\u53D1\u6587\u4EF6\u5373\u53EF\u4E0A\u4F20 (\u6700\u5927 2GB\uFF0C\u8D26\u53F7\u7EA7\u4E0B\u8F7D\u5668\u4E0D\u53D7\u6B64\u9650\u5236)`, - `\u{1F510} /setup_2fa \u2014 \u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1`, - `\u{1F4E5} /ytdlp \u2014 \u89E3\u6790\u5E76\u4E0B\u8F7D\u94FE\u63A5`, + `\u{1F4C1} /path_rules \u2014 \u4FDD\u5B58\u8DEF\u5F84/\u81EA\u5B9A\u4E49\u76EE\u5F55`, `\u{1F4E1} /tg_sub \u2014 \u8BA2\u9605\u9891\u9053\u81EA\u52A8\u540C\u6B65`, - `\u{1F5D3}\uFE0F /tg_date \u2014 \u6309\u65E5\u671F\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6`, - `\u{1F3F7}\uFE0F /tg_tag \u2014 \u6309\u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6`, - `\u{1F9FE} /tg_jobs \u2014 Telegram \u540E\u53F0\u4EFB\u52A1`, - `\u{1F4CA} /storage \u2014 \u5B58\u50A8\u7A7A\u95F4\u6982\u89C8`, - `\u{1F4CB} /list \u2014 \u6700\u8FD1\u4E0A\u4F20\u8BB0\u5F55`, - `\u{1F527} /tasks \u2014 \u5B9E\u65F6\u4EFB\u52A1\u961F\u5217`, - `\u{1F6D1} /stop_tasks \u2014 \u5F3A\u5236\u505C\u6B62\u4E0B\u8F7D\u4EFB\u52A1`, + `\u{1F4E6} /tg_download \u2014 \u6309\u65E5\u671F/\u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6`, `\u2699\uFE0F /download_workers \u2014 \u4E0B\u8F7D\u5E76\u53D1\u8BBE\u7F6E`, - `\u{1F4C1} /path_rules \u2014 \u4FDD\u5B58\u8DEF\u5F84\u89C4\u5219`, `\u{1F9EC} /duplicate_mode \u2014 \u91CD\u590D\u6587\u4EF6\u5904\u7406`, `\u{1F9F9} /cleanup_settings \u2014 \u81EA\u52A8\u6E05\u7406\u8BBE\u7F6E`, + `\u{1F4CA} /storage \u2014 \u5B58\u50A8\u7EDF\u8BA1/\u6E05\u7406\u672C\u5730\u6587\u4EF6`, + `\u{1F527} /tasks \u2014 \u5B9E\u65F6\u4EFB\u52A1\u961F\u5217`, + `\u{1F510} /setup_2fa \u2014 \u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1`, + `\u{1F4E5} /ytdlp \u2014 \u89E3\u6790\u5E76\u4E0B\u8F7D\u94FE\u63A5`, `\u2753 /help \u2014 \u5B8C\u6574\u5E2E\u52A9` ].join("\n"); } @@ -2107,21 +2196,20 @@ function buildHelp() { `**\u{1F6E0} \u53EF\u7528\u547D\u4EE4**`, ` /start \u2014 \u8EAB\u4EFD\u8BA4\u8BC1 / \u5F00\u59CB\u4F7F\u7528`, ` /setup\\_2fa \u2014 \u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1 (TOTP)`, - ` /ytdlp \u2014 \u4E0B\u8F7D\u89C6\u9891\u94FE\u63A5\u5230\u5B58\u50A8`, + ` /path_rules \u2014 \u4FDD\u5B58\u8DEF\u5F84/\u81EA\u5B9A\u4E49\u76EE\u5F55\u9762\u677F`, + ` /p <\u76EE\u5F55> \u2014 \u4E0B\u4E00\u6B21\u4E0B\u8F7D\u4FDD\u5B58\u5230\u6307\u5B9A\u76EE\u5F55`, + ` /ps <\u76EE\u5F55> \u2014 \u672C\u4F1A\u8BDD\u6301\u7EED\u4FDD\u5B58\u5230\u6307\u5B9A\u76EE\u5F55`, + ` /pc \u2014 \u6E05\u9664\u81EA\u5B9A\u4E49\u76EE\u5F55`, ` /tg_sub <\u9891\u9053> \u2014 \u8BA2\u9605\u9891\u9053\u65B0\u6587\u4EF6\u81EA\u52A8\u540C\u6B65`, - ` /tg_subs \u2014 \u67E5\u770B\u9891\u9053\u8BA2\u9605`, - ` /tg_unsub <\u9891\u9053\u6216ID> \u2014 \u53D6\u6D88\u8BA2\u9605`, - ` /tg_date <\u9891\u9053> <\u5F00\u59CB\u65E5\u671F> <\u7ED3\u675F\u65E5\u671F> \u2014 \u6309\u65E5\u671F\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6`, - ` /tg_tag <\u9891\u9053> <#\u6807\u7B7E> \u2014 \u4E0B\u8F7D\u9891\u9053\u5185\u6307\u5B9A\u6807\u7B7E\u7684\u5168\u90E8\u6587\u4EF6`, - ` /tg_jobs \u2014 \u67E5\u770B Telegram \u540E\u53F0\u4EFB\u52A1`, - ` /storage \u2014 \u670D\u52A1\u5668 & \u5B58\u50A8\u7EDF\u8BA1`, - ` /list [n] \u2014 \u6700\u8FD1\u4E0A\u4F20 (\u9ED8\u8BA4 10 \u6761)`, - ` /tasks \u2014 \u5B9E\u65F6\u4F20\u8F93\u4EFB\u52A1\u961F\u5217`, - ` /stop_tasks \u2014 \u505C\u6B62\u6240\u6709\u4E0B\u8F7D\u4EFB\u52A1`, + ` /tg_download \u2014 \u6309\u65E5\u671F/\u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6`, + ` /tg_download date <\u9891\u9053> <\u5F00\u59CB\u65E5\u671F> <\u7ED3\u675F\u65E5\u671F> \u2014 \u6309\u65E5\u671F\u4E0B\u8F7D`, + ` /tg_download tag <\u9891\u9053> <#\u6807\u7B7E> \u2014 \u6309\u6807\u7B7E\u4E0B\u8F7D`, ` /download_workers \u2014 \u8BBE\u7F6E\u4E0B\u8F7D\u5E76\u53D1`, - ` /path_rules \u2014 \u8BBE\u7F6E\u4FDD\u5B58\u8DEF\u5F84\u89C4\u5219`, ` /duplicate_mode \u2014 \u8BBE\u7F6E\u91CD\u590D\u6587\u4EF6\u5904\u7406`, ` /cleanup_settings \u2014 \u8BBE\u7F6E\u81EA\u52A8\u6E05\u7406\u5F00\u5173`, + ` /storage \u2014 \u5B58\u50A8\u7EDF\u8BA1/\u6E05\u7406\u672C\u5730\u6587\u4EF6`, + ` /tasks \u2014 \u5B9E\u65F6\u4F20\u8F93\u4EFB\u52A1\u961F\u5217`, + ` /ytdlp \u2014 \u4E0B\u8F7D\u89C6\u9891\u94FE\u63A5\u5230\u5B58\u50A8`, ` /delete \u2014 \u5220\u9664\u6307\u5B9A\u6587\u4EF6`, ` /help \u2014 \u663E\u793A\u6B64\u5E2E\u52A9`, ``, @@ -2151,44 +2239,19 @@ function buildStorageReport(data) { ` \u53EF\u3000\u7528\u3000${formatBytes(data.diskFree)}`, ` ${usageBar}`, ``, - `**\u{1F4C1} FlClouds \u6587\u4EF6**`, + `**\u{1F4C1} \u5B58\u50A8\u6E90\u6587\u4EF6**`, ` \u6587\u4EF6\u6570\u3000${data.fileCount} \u4E2A`, ` \u5360\u3000\u7528\u3000${formatBytes(data.totalFileSize)}`, ``, + `**\u{1F5A5}\uFE0F \u672C\u5730\u670D\u52A1\u5668\u4E0B\u8F7D\u6587\u4EF6**`, + ` \u6587\u4EF6\u6570\u3000${data.localFileCount} \u4E2A`, + ` \u5360\u3000\u7528\u3000${formatBytes(data.localTotalSize)}`, + ` \u4F4D\u7F6E\u3000uploads \u672C\u5730\u7F13\u5B58/\u4E0B\u8F7D\u76EE\u5F55`, + ``, `**\u{1F4E1} \u4E0B\u8F7D\u961F\u5217**`, ` \u{1F504} \u5904\u7406\u4E2D ${data.queueActive}\u3000\u23F3 \u7B49\u5F85\u4E2D ${data.queuePending}` ].join("\n"); } -function buildFileList(files, total) { - const lines = [ - `\u{1F4CB} **\u6700\u8FD1\u4E0A\u4F20\u7684\u6587\u4EF6** (${total} \u6761)`, - LINE - ]; - files.forEach((file, index) => { - const typeEmoji = getTypeEmoji( - file.type === "image" ? "image/" : file.type === "video" ? "video/" : file.type === "audio" ? "audio/" : "other" - ); - const size = formatBytes(typeof file.size === "string" ? parseInt(file.size) : file.size); - const date = new Date(file.created_at).toLocaleString("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit" - }); - let displayName = file.name; - if (displayName.length > 25) { - displayName = displayName.substring(0, 22) + "..."; - } - lines.push(`${index + 1}. ${typeEmoji} **${displayName}**`); - lines.push(` ${size} \xB7 ${date}${file.folder ? ` \xB7 \u{1F4C1} ${file.folder}` : ""}`); - lines.push(` ID: \`${file.id.substring(0, 8)}\``); - }); - lines.push(""); - lines.push(`\u{1F4A1} \u5220\u9664\u6587\u4EF6: \u590D\u5236\u4E0A\u65B9 ID \u540E\u53D1\u9001 /delete `); - lines.push(` \u4F8B\uFF1A/delete ${files[0]?.id?.substring(0, 8) || "a1b2c3d4"}`); - lines.push(`\u{1F4C4} \u66F4\u591A\u8BB0\u5F55: /list 20 \u6216 /list 20 2`); - return lines.join("\n"); -} function buildTasksReport(active, pending, history) { const lines = [ `\u{1F4CB} **\u4EFB\u52A1\u961F\u5217\u72B6\u6001**`, @@ -2571,7 +2634,7 @@ function buildCleanupNotice(deletedCount, freedSpace) { // src/services/telegramUpload.ts init_db(); -import { Api as Api2 } from "telegram"; +import { Api as Api3 } from "telegram"; import fs7 from "fs"; import path10 from "path"; import crypto6 from "crypto"; @@ -2860,12 +2923,7 @@ function getDetailedTypeFolder(mimeType, fileName) { return getTypeFolder(mimeType); } async function getStoragePathRules() { - 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, fallback) { const cleaned = sanitizeFilename((value || fallback).trim()).replace(/^\.+/, "_"); @@ -2944,6 +3002,196 @@ async function getTelegramBatchFolderName(message, fallback) { return normalizeSegment(fallback, "telegram-batch"); } +// src/utils/telegramPathSettings.ts +import { Api as Api2 } from "telegram"; +var chatPathState = /* @__PURE__ */ new Map(); +var pendingPathInputState = /* @__PURE__ */ new Map(); +var recentPathState = /* @__PURE__ */ new Map(); +var MAX_RECENT_PATHS = 6; +var RECENT_PATH_SETTING_PREFIX = "telegram_recent_paths:"; +function pendingPathInputKey(chatId, userId) { + return `${chatId}:${userId}`; +} +function recentPathSettingKey(chatId) { + return `${RECENT_PATH_SETTING_PREFIX}${chatId}`; +} +function normalizePathSegment(segment) { + return sanitizeFilename(segment.trim()).replace(/^\.+/, "_").replace(/^\.+$/, "_"); +} +function parseRecentPaths(raw) { + 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) { + const cached = recentPathState.get(chatId); + if (cached) return [...cached]; + const raw = await getSetting(recentPathSettingKey(chatId), "[]"); + const loaded = parseRecentPaths(raw); + recentPathState.set(chatId, loaded); + return [...loaded]; +} +async function persistRecentTelegramPaths(chatId, paths) { + recentPathState.set(chatId, paths); + await setSetting(recentPathSettingKey(chatId), JSON.stringify(paths)); +} +function sanitizeCustomStoragePath(input) { + const raw = input.trim().replace(/\\+/g, "/").replace(/\/+/g, "/").replace(/^\/+|\/+$/g, ""); + if (!raw) throw new Error("\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A"); + if (raw.startsWith("~") || raw.includes("\0")) throw new Error("\u8DEF\u5F84\u5305\u542B\u975E\u6CD5\u5B57\u7B26"); + const segments = raw.split("/").map((segment) => segment.trim()).filter(Boolean); + if (segments.length === 0) throw new Error("\u8DEF\u5F84\u4E0D\u80FD\u4E3A\u7A7A"); + if (segments.some((segment) => segment === "." || segment === ".." || segment.includes(".."))) { + throw new Error("\u8DEF\u5F84\u4E0D\u80FD\u5305\u542B . \u6216 .."); + } + const normalized = segments.map((segment) => normalizePathSegment(segment)).filter(Boolean).join("/"); + if (!normalized) throw new Error("\u8DEF\u5F84\u65E0\u6548"); + if (normalized.length > 180) throw new Error("\u8DEF\u5F84\u8FC7\u957F\uFF0C\u8BF7\u63A7\u5236\u5728 180 \u4E2A\u5B57\u7B26\u5185"); + return normalized; +} +async function rememberRecentTelegramPathPersistent(chatId, folder) { + 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; +} +async function getRecentTelegramPathsPersistent(chatId) { + return loadRecentTelegramPaths(chatId); +} +function buildPathPreviewLine(folder) { + return `\u4FDD\u5B58\u5230\uFF1A${folder}/\u6587\u4EF6\u540D\uFF08\u4E0D\u4F1A\u8FFD\u52A0\u9891\u9053\u540D\u6216\u6587\u4EF6\u7C7B\u578B\u76EE\u5F55\uFF09`; +} +function getTelegramPathState(chatId) { + return { ...chatPathState.get(chatId) || {} }; +} +async function setNextTelegramPathPersistent(chatId, folder) { + const normalized = await rememberRecentTelegramPathPersistent(chatId, folder); + const state = chatPathState.get(chatId) || {}; + state.nextFolder = normalized; + chatPathState.set(chatId, state); + return normalized; +} +async function setSessionTelegramPathPersistent(chatId, folder) { + const normalized = await rememberRecentTelegramPathPersistent(chatId, folder); + const state = chatPathState.get(chatId) || {}; + state.sessionFolder = normalized; + chatPathState.set(chatId, state); + return normalized; +} +function clearTelegramPathState(chatId) { + chatPathState.delete(chatId); +} +function setPendingTelegramPathInput(chatId, userId, mode) { + pendingPathInputState.set(pendingPathInputKey(chatId, userId), mode); +} +function getPendingTelegramPathInput(chatId, userId) { + return pendingPathInputState.get(pendingPathInputKey(chatId, userId)); +} +function clearPendingTelegramPathInput(chatId, userId) { + pendingPathInputState.delete(pendingPathInputKey(chatId, userId)); +} +async function applyPendingTelegramPathInputPersistent(chatId, userId, folder) { + 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 }; +} +async function buildPendingPathPromptPersistent(mode, chatId) { + const recent = chatId ? await getRecentTelegramPathsPersistent(chatId) : []; + return [ + mode === "once" ? "\u{1F4CC} **\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u4E0B\u8F7D\u76EE\u5F55**" : "\u{1F4CD} **\u8BBE\u7F6E\u4F1A\u8BDD\u4E0B\u8F7D\u76EE\u5F55**", + "", + "\u8BF7\u76F4\u63A5\u53D1\u9001\u76EE\u5F55\u540D\u79F0\uFF1A", + mode === "once" ? "\u4F8B\u5982\uFF1A`PIXIV/\u6BCF\u65E5Top50`" : "\u4F8B\u5982\uFF1A`\u76F8\u518C/2026-07`", + ...recent.length > 0 ? ["", "\u6700\u8FD1\u4F7F\u7528\u76EE\u5F55\uFF1A", ...recent.slice(0, 4).map((item) => `- ${item}`)] : [], + "", + mode === "once" ? "\u8BF4\u660E\uFF1A\u53EA\u5F71\u54CD\u4E0B\u4E00\u6B21\u8FDB\u5165\u4E0B\u8F7D\u6D41\u7A0B\u7684\u6587\u4EF6\u3002" : "\u8BF4\u660E\uFF1A\u4F1A\u5F71\u54CD\u5F53\u524D\u804A\u5929\u540E\u7EED\u4E0B\u8F7D\uFF0C\u76F4\u5230\u53D1\u9001 `/pc` \u6216\u70B9\u51FB\u6E05\u9664\u3002", + "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u672C\u6B21\u8BBE\u7F6E\u3002" + ].join("\n"); +} +function resolveTelegramStorageFolder(chatId, automaticFolder) { + 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; +} +function resolveTelegramBatchStorageFolder(chatId, automaticFolder) { + return resolveTelegramStorageFolder(chatId, automaticFolder); +} +function resolveTelegramTaskStorageFolder(chatId, automaticFolder) { + 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 }; +} +function buildTelegramPathStateLines(chatId) { + const state = getTelegramPathState(chatId); + const active = state.nextFolder || state.sessionFolder; + return [ + `\u5F53\u524D\u4FDD\u5B58\uFF1A${active ? `\`${active}\`\uFF08\u81EA\u5B9A\u4E49\u76EE\u5F55\uFF09` : "\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B"}`, + active ? buildPathPreviewLine(active) : "\u9ED8\u8BA4\u793A\u4F8B\uFF1A`telegram/\u8D44\u6E90\u4E0B\u8F7D/images`", + `\u{1F4CC} \u4E0B\u4E00\u6B21\u76EE\u5F55\uFF1A${state.nextFolder ? `\`${state.nextFolder}\`` : "\u672A\u8BBE\u7F6E"}`, + `\u{1F4CD} \u672C\u4F1A\u8BDD\u76EE\u5F55\uFF1A${state.sessionFolder ? `\`${state.sessionFolder}\`` : "\u672A\u8BBE\u7F6E"}` + ]; +} +function buildPathSettingsKeyboard(_state) { + return new Api2.ReplyInlineMarkup({ + rows: [ + new Api2.KeyboardButtonRow({ + buttons: [ + new Api2.KeyboardButtonCallback({ text: "\u{1F4CC} \u8BBE\u7F6E\u4E0B\u4E00\u6B21\u76EE\u5F55", data: Buffer.from("pr_help_once") }), + new Api2.KeyboardButtonCallback({ text: "\u{1F4CD} \u8BBE\u7F6E\u4F1A\u8BDD\u76EE\u5F55", data: Buffer.from("pr_help_session") }) + ] + }), + new Api2.KeyboardButtonRow({ + buttons: [ + new Api2.KeyboardButtonCallback({ text: "\u{1F558} \u6700\u8FD1\u76EE\u5F55", data: Buffer.from("pr_recent") }), + new Api2.KeyboardButtonCallback({ text: "\u{1F9F9} \u6E05\u9664\u81EA\u5B9A\u4E49\u76EE\u5F55", data: Buffer.from("pr_clear_custom") }) + ] + }) + ] + }); +} +function buildPathSettingsText(_state, chatId) { + return [ + "\u{1F4C1} **\u4FDD\u5B58\u4F4D\u7F6E**", + "", + "**\u9ED8\u8BA4\u4FDD\u5B58\u903B\u8F91**", + "\u672A\u8BBE\u7F6E\u81EA\u5B9A\u4E49\u76EE\u5F55\u65F6\uFF1A\u81EA\u52A8\u6309\u6765\u6E90/\u9891\u9053 + \u6587\u4EF6\u7C7B\u578B\u4FDD\u5B58\u3002", + "\u4F8B\u5982\uFF1A`telegram/\u8D44\u6E90\u4E0B\u8F7D/images`\u3001`telegram/\u8D44\u6E90\u4E0B\u8F7D/videos`\u3002", + "\u8BBE\u7F6E\u81EA\u5B9A\u4E49\u76EE\u5F55\u540E\uFF1A\u6587\u4EF6\u4F1A\u76F4\u63A5\u4FDD\u5B58\u5230\u8BE5\u76EE\u5F55\u672C\u8EAB\uFF0C\u4E0D\u518D\u8FFD\u52A0\u9891\u9053\u540D\u6216\u6587\u4EF6\u7C7B\u578B\u76EE\u5F55\u3002", + "", + "**\u5F53\u524D\u8DEF\u5F84\u72B6\u6001**", + ...buildTelegramPathStateLines(chatId), + "", + "**\u5FEB\u6377\u547D\u4EE4**", + "`/p <\u76EE\u5F55>` \u2014 \u4EC5\u4E0B\u4E00\u6B21\u4E0B\u8F7D\u4F7F\u7528", + "`/ps <\u76EE\u5F55>` \u2014 \u5F53\u524D\u4F1A\u8BDD\u6301\u7EED\u4F7F\u7528", + "`/pc` \u2014 \u6E05\u9664\u4E0B\u4E00\u6B21/\u4F1A\u8BDD\u76EE\u5F55", + "", + "\u4F18\u5148\u7EA7\uFF1A\u4E0B\u4E00\u6B21\u76EE\u5F55 > \u672C\u4F1A\u8BDD\u76EE\u5F55 > \u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B\u76EE\u5F55\u3002", + "\u8DEF\u5F84\u793A\u4F8B\uFF1A\u8BBE\u7F6E `/ps book` \u540E\uFF0C\u6587\u4EF6\u76F4\u63A5\u4FDD\u5B58\u5230 `book`\u3002" + ].join("\n"); +} + // src/utils/duplicatePolicy.ts init_db(); function normalizeDuplicateMode(value) { @@ -3105,7 +3353,10 @@ function shouldRefreshLargeTaskStatus(lastStatusRefresh, completed, force = fals return force || completed <= 3 || completed % 20 === 0 || Date.now() - lastStatusRefresh >= TG_LARGE_TASK_REFRESH_INTERVAL_MS; } async function safeEditMessage(client2, chatId, params) { - if (Date.now() < floodWaitUntil) return null; + if (Date.now() < floodWaitUntil) { + console.warn(`[Telegram] \u23F3 \u8DF3\u8FC7\u7F16\u8F91\u6D88\u606F\uFF1A\u4ECD\u5728 FloodWait \u51B7\u5374\u4E2D chat=${chatId.toString()} msg=${params?.message}`); + return null; + } try { const result = await client2.editMessage(chatId, params); if (process.env.TG_STATUS_DEBUG === "1") { @@ -3120,9 +3371,34 @@ async function safeEditMessage(client2, chatId, params) { floodWaitUntil = Date.now() + seconds * 1e3; console.warn(`[Telegram] \u26A0\uFE0F \u89E6\u53D1 FloodWait\uFF0C\u51B7\u5374\u65F6\u95F4: ${seconds} \u79D2`); } + 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] \u26A0\uFE0F \u7F16\u8F91\u6D88\u606F\u5931\u8D25 chat=${chatId.toString()} msg=${params?.message}:`, e?.errorMessage || e?.message || e); return null; } } +var telegramStorageWriteLocks = /* @__PURE__ */ new Map(); +async function withTelegramStorageWriteLock(key, fn) { + const previous = telegramStorageWriteLocks.get(key) || Promise.resolve(); + let release; + const current = new Promise((resolve) => { + release = resolve; + }); + telegramStorageWriteLocks.set(key, previous.then(() => current, () => current)); + await previous.catch(() => void 0); + try { + return await fn(); + } finally { + release(); + if (telegramStorageWriteLocks.get(key) === current) { + telegramStorageWriteLocks.delete(key); + } + } +} var silentNoticePromiseMap = /* @__PURE__ */ new Map(); async function ensureSilentNotice(client2, chatId, fileCount, replyToMsg) { const chatIdStr = chatId.toString(); @@ -3387,6 +3663,7 @@ var lastStatusMessageIdMap = /* @__PURE__ */ new Map(); var silentNoticeMessageIdMap = /* @__PURE__ */ new Map(); var silentSessionMap = /* @__PURE__ */ new Map(); var taskIdToChatId = /* @__PURE__ */ new Map(); +var taskIdControlScopes = /* @__PURE__ */ new Map(); function createSessionTaskId() { return `t${Date.now().toString(36).slice(-5)}${Math.random().toString(36).slice(2, 5)}`; } @@ -3397,13 +3674,30 @@ function resolveTaskChatId(taskId) { if (!taskId) return void 0; return taskIdToChatId.get(taskId.trim()); } +function registerTaskControlScope(taskId, chatId, userId) { + taskIdToChatId.set(taskId, chatId); + taskIdControlScopes.set(taskId, { chatId, userId }); +} +function removeTaskControlScope(taskId) { + if (!taskId) return; + taskIdToChatId.delete(taskId); + taskIdControlScopes.delete(taskId); +} +function canControlTask(taskId, chatId, userId) { + if (!taskId || !chatId) return false; + const scope = taskIdControlScopes.get(taskId.trim()); + if (!scope) return false; + if (scope.chatId !== chatId) return false; + if (scope.userId !== void 0 && userId !== void 0 && scope.userId !== userId) return false; + return true; +} function getSilentSession(chatIdStr) { let s = silentSessionMap.get(chatIdStr); if (!s) { const taskId = createSessionTaskId(); s = { total: 0, completed: 0, failed: 0, taskId, knownTaskKeys: /* @__PURE__ */ new Set(), knownTaskCounts: /* @__PURE__ */ new Map(), folders: /* @__PURE__ */ new Set(), providers: /* @__PURE__ */ new Set() }; silentSessionMap.set(chatIdStr, s); - taskIdToChatId.set(taskId, chatIdStr); + registerTaskControlScope(taskId, chatIdStr); } return s; } @@ -3411,7 +3705,7 @@ function startSilentSession(chatIdStr, total) { const taskId = createSessionTaskId(); const s = { total, completed: 0, failed: 0, taskId, knownTaskKeys: /* @__PURE__ */ new Set(), knownTaskCounts: /* @__PURE__ */ new Map(), folders: /* @__PURE__ */ new Set(), providers: /* @__PURE__ */ new Set() }; silentSessionMap.set(chatIdStr, s); - taskIdToChatId.set(taskId, chatIdStr); + registerTaskControlScope(taskId, chatIdStr); return s; } async function finalizeSilentSessionIfDone(client2, chatId) { @@ -3441,11 +3735,19 @@ async function finalizeSilentSessionIfDone(client2, chatId) { })) ] ); - const controls = s?.failed ? buildTaskControlButtons(s?.taskId) : new Api2.ReplyInlineMarkup({ rows: [] }); - await safeEditMessage(client2, chatId, { message: silentMsgId, text, buttons: controls }); + const controls = s?.failed ? buildTaskControlButtons(s?.taskId) : void 0; + const edited = await safeEditMessage(client2, chatId, { message: silentMsgId, text, buttons: controls }); + if (!edited) { + try { + await client2.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); console.log(`[TG][silent] finalized chat=${chatIdStr} failed=${s?.failed || 0}`); @@ -3699,7 +4001,7 @@ async function refreshSilentProgress(client2, chatId) { queueStats.paused, queueStats.pauseReason ); - const buttons = isComplete && session.failed === 0 ? new Api2.ReplyInlineMarkup({ rows: [] }) : buildTaskControlButtons(session.taskId); + const buttons = isComplete && session.failed === 0 ? void 0 : buildTaskControlButtons(session.taskId); await safeEditMessage(client2, chatId, { message: silentMsgId, text, buttons }); } async function checkAndResetSession(client2, chatId) { @@ -3708,7 +4010,7 @@ async function checkAndResetSession(client2, chatId) { 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}`); @@ -3767,23 +4069,28 @@ function resolveTaskChatIdForControl(taskId) { function forceStopDownloadTasks(reason) { return downloadQueue.forceStopAll(reason); } -function pauseDownloadTasks(_taskId) { +function pauseDownloadTasks(taskId) { + if (taskId && !resolveTaskChatId(taskId)) return { active: 0, pending: 0, total: 0 }; return downloadQueue.pause(); } -function resumeDownloadTasks(_taskId) { +function resumeDownloadTasks(taskId) { + if (taskId && !resolveTaskChatId(taskId)) return { active: 0, pending: 0, total: 0 }; return downloadQueue.resume(); } function cancelDownloadTask(selector) { const normalized = selector?.trim(); if (normalized && resolveTaskChatId(normalized)) { - return downloadQueue.forceStopAll(`\u7528\u6237\u901A\u8FC7 /task_cancel ${normalized} \u53D6\u6D88\u4EFB\u52A1`); + return downloadQueue.cancel("no-such-task-selector", `\u7528\u6237\u901A\u8FC7 /task_cancel ${normalized} \u53D6\u6D88\u4EFB\u52A1`); } return downloadQueue.cancel(normalized, "\u7528\u6237\u901A\u8FC7 /task_cancel \u53D6\u6D88\u4EFB\u52A1"); } -async function cancelSilentTask(client2, chatId, taskId, fallbackMessageId) { +async function cancelSilentTask(client2, chatId, taskId, fallbackMessageId, userId) { const mappedChatId = resolveTaskChatId(taskId); const chatIdStr = mappedChatId || chatId.toString(); const editChatId = mappedChatId || chatId; + if (!canControlTask(taskId, chatIdStr, userId)) { + throw new Error("\u4EFB\u52A1\u4E0D\u5C5E\u4E8E\u5F53\u524D\u804A\u5929\u6216\u5DF2\u5931\u6548"); + } const session = silentSessionMap.get(chatIdStr); const silentMsgId = silentNoticeMessageIdMap.get(chatIdStr) || fallbackMessageId; const result = cancelDownloadTask(taskId); @@ -3802,11 +4109,11 @@ async function cancelSilentTask(client2, chatId, taskId, fallbackMessageId) { ``, `\u5DF2\u79FB\u9664\u6682\u505C / \u7EE7\u7EED / \u53D6\u6D88\u6309\u94AE\uFF0C\u6B64\u4EFB\u52A1\u4E0D\u4F1A\u518D\u54CD\u5E94\u65E7\u6309\u94AE\u64CD\u4F5C\u3002` ].join("\n"); - await safeEditMessage(client2, editChatId, { message: silentMsgId, text, buttons: new Api2.ReplyInlineMarkup({ rows: [] }) }); + await safeEditMessage(client2, editChatId, { message: silentMsgId, text, buttons: new Api3.ReplyInlineMarkup({ rows: [] }) }); } 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); @@ -3891,7 +4198,7 @@ function extractFileInfo(message) { mimeType = "image/webp"; } else { const media = message.media; - if (media.document && media.document instanceof Api2.Document) { + if (media.document && media.document instanceof Api3.Document) { const doc = media.document; const fileNameAttr = doc.attributes?.find((a) => a.className === "DocumentAttributeFilename"); fileName = fileNameAttr?.fileName || `file_${message.id}`; @@ -4025,14 +4332,15 @@ async function processFileUpload(client2, file, queue) { 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 !== void 0 ? file.folderOverride : queue?.storageFolder !== void 0 ? queue.storageFolder : resolveTelegramStorageFolder(chatIdForPath, automaticFolder); const downloadSource = await resolveDownloadSource(client2, file.message); const result = await downloadAndSaveFile(downloadSource.client, downloadSource.message, file.fileName, file.targetDir, void 0, signal); if (!result) { @@ -4059,41 +4367,47 @@ async function processFileUpload(client2, file, queue) { return true; } } - let thumbnailPath = null; - let dimensions = {}; - try { - thumbnailPath = await generateThumbnail(localFilePath, storedName, file.mimeType); - dimensions = await getImageDimensions(localFilePath, file.mimeType); - } catch (thumbErr) { - console.warn("\u{1F916} \u751F\u6210\u7F29\u7565\u56FE/\u83B7\u53D6\u5C3A\u5BF8\u5931\u8D25\uFF0C\u7EE7\u7EED\u4E0A\u4F20:", thumbErr); - } const provider = storageManager.getProvider(); - let finalPath = localFilePath; - let sourceRef = provider.name; - try { - finalPath = await provider.saveFile(localFilePath, storedName, file.mimeType, storageFolder); - if (fs7.existsSync(localFilePath)) { - fs7.unlinkSync(localFilePath); + const storageLockKey = `${provider.name}:${activeAccountId || "local"}:${storageFolder || ""}`; + return await withTelegramStorageWriteLock(storageLockKey, async () => { + storedName = await getUniqueStoredName(file.fileName, storageFolder, activeAccountId); + let thumbnailPath = null; + let dimensions = {}; + 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("\u{1F916} \u751F\u6210\u7F29\u7565\u56FE/\u83B7\u53D6\u5C3A\u5BF8\u5931\u8D25\uFF0C\u7EE7\u7EED\u4E0A\u4F20:", thumbErr); + } } - localFilePath = void 0; - } catch (err) { - console.error("\u4FDD\u5B58\u6587\u4EF6\u5230\u5B58\u50A8\u63D0\u4F9B\u5546\u5931\u8D25:", 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.groupedId?.toString(); - if (batchId) updateBatch(chatIdStr, batchId, { folderPath: storageFolder || void 0, providerName: provider.name }); - rememberTransferDestination(chatIdStr, storageFolder, provider.name); - } - return true; + let finalPath = localFilePath; + let sourceRef = provider.name; + try { + finalPath = await provider.saveFile(localFilePath, storedName, file.mimeType, storageFolder); + if (fs7.existsSync(localFilePath)) { + fs7.unlinkSync(localFilePath); + } + localFilePath = void 0; + } catch (err) { + console.error("\u4FDD\u5B58\u6587\u4EF6\u5230\u5B58\u50A8\u63D0\u4F9B\u5546\u5931\u8D25:", 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.groupedId?.toString(); + if (batchId) updateBatch(chatIdStr, batchId, { folderPath: storageFolder || void 0, providerName: provider.name }); + rememberTransferDestination(chatIdStr, storageFolder, provider.name); + } + return true; + }); } catch (error) { console.error("\u{1F916} \u6587\u4EF6\u4E0A\u4F20\u5931\u8D25:", error); file.error = error.message; @@ -4177,13 +4491,16 @@ async function processBatchUpload(client2, mediaGroupId) { 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 || void 0 }); queue.folderPath = folderPreview || void 0; rememberTransferDestination(chatId.toString(), folderPreview, storageManager.getProvider().name); @@ -4267,7 +4584,7 @@ async function handleCleanupCallback(cleanupId) { return { success: false, message: `\u6E05\u7406\u5931\u8D25: ${error.message}` }; } } -async function downloadTelegramChannelRange(botClient, requestMessage, source, startMessageId, limit = 50, direction = "older", explicitIds) { +async function downloadTelegramChannelRange(botClient, requestMessage, source, startMessageId, limit = 50, direction = "older", explicitIds, folderOverride) { const userClient2 = getTelegramUserClient(); if (!userClient2 || !isTelegramUserClientReady()) { throw new Error("Telegram \u7528\u6237\u8D26\u53F7\u4E0B\u8F7D\u5668\u672A\u5C31\u7EEA\uFF1A\u8BF7\u5148\u914D\u7F6E TELEGRAM_USER_API_ID / TELEGRAM_USER_API_HASH \u5E76\u751F\u6210 user session"); @@ -4286,6 +4603,8 @@ async function downloadTelegramChannelRange(botClient, requestMessage, source, s let found = 0; let skipped = 0; const chatIdStr = chatId.toString(); + const taskFolderOverride = folderOverride !== void 0 ? folderOverride : void 0; + let taskResolvedStorageFolder; const downloadableRefs = []; const successfulMessageIds = []; const failedMessageIds = []; @@ -4314,10 +4633,27 @@ async function downloadTelegramChannelRange(botClient, requestMessage, source, s } const batchId = `tg-range-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; if (downloadableRefs.length > 0) { + const firstRef = downloadableRefs[0]; + if (taskFolderOverride !== void 0) { + taskResolvedStorageFolder = taskFolderOverride; + } else if (firstRef) { + const firstMessage = (await userClient2.getMessages(sourceEntity, { ids: [firstRef.id] }))[0]; + 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: void 0, + folderPath: taskResolvedStorageFolder || void 0, totalFiles: downloadableRefs.length, completed: 0, successful: 0, @@ -4378,17 +4714,22 @@ async function downloadTelegramChannelRange(botClient, requestMessage, source, s status: "pending" }; try { - if (!getConsolidatedBatches(chatIdStr).find((batch) => batch.id === batchId)?.folderPath) { + if (taskResolvedStorageFolder !== void 0) { + updateBatch(chatIdStr, batchId, { folderPath: taskResolvedStorageFolder || void 0 }); + } 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 || void 0 }); + const resolved = resolveTelegramTaskStorageFolder(chatIdStr, automaticPreview).folder; + taskResolvedStorageFolder = resolved; + updateBatch(chatIdStr, batchId, { folderPath: resolved || void 0 }); } + uploadItem.folderOverride = taskResolvedStorageFolder !== void 0 ? taskResolvedStorageFolder : taskFolderOverride; await processFileUpload(userClient2, uploadItem); if (uploadItem.status === "success") { successful += 1; @@ -4432,7 +4773,7 @@ async function handleFileUpload(client2, event) { const message = event.message; const senderId = message.senderId?.toJSNumber(); if (!senderId) return; - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED_UPLOAD }); return; } @@ -4493,6 +4834,8 @@ async function handleFileUpload(client2, event) { } if (message.chatId) { await trySilentMode(client2, message.chatId, message); + const taskId = getSessionTaskId(message.chatId.toString()); + if (taskId) registerTaskControlScope(taskId, message.chatId.toString(), senderId); } } else { let finalFileName = fileName; @@ -4524,6 +4867,8 @@ async function handleFileUpload(client2, event) { let statusMsg; const useConsolidated = () => getActiveUploadCount(chatIdStr) >= 2 || getActiveBatchCount(chatIdStr) > 0; await trySilentMode(client2, chatId, message); + const silentTaskId = getSessionTaskId(chatIdStr); + if (silentTaskId) registerTaskControlScope(silentTaskId, chatIdStr, senderId); if (!silentSessionMap.has(chatIdStr) && getBackgroundFileCount(chatIdStr) <= 3) { await runStatusAction(chatId, async () => { if (useConsolidated()) { @@ -4584,12 +4929,13 @@ async function handleFileUpload(client2, event) { 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); const downloadSource = await resolveDownloadSource(client2, message); const result = await downloadAndSaveFile(downloadSource.client, downloadSource.message, fileName, void 0, onProgress, signal); @@ -4637,14 +4983,16 @@ async function handleFileUpload(client2, event) { }); }); } + const provider = storageManager.getProvider(); let thumbnailPath = null; let dimensions = {}; - try { - thumbnailPath = await generateThumbnail(localFilePath, storedName, mimeType); - dimensions = await getImageDimensions(localFilePath, mimeType); - } catch (thumbErr) { + 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; try { @@ -4776,8 +5124,360 @@ async function handleFileUpload(client2, event) { } } -// src/services/telegramCommands.ts -init_storage(); +// src/services/telegramChannelJobs.ts +init_db(); +var SUBSCRIPTION_INTERVAL_MS = Math.max(6e4, parseInt(process.env.TELEGRAM_SUBSCRIPTION_INTERVAL_MS || "300000", 10) || 3e5); +var SUBSCRIPTION_SCAN_LIMIT = Math.max(1, parseInt(process.env.TELEGRAM_SUBSCRIPTION_SCAN_LIMIT || "100", 10) || 100); +var subscriptionTimer = null; +function parseTelegramSourceAllowlist(raw) { + return (raw || "").split(",").map((item) => item.trim()).filter(Boolean).map((item) => normalizeSource(item).toLowerCase()); +} +async function getTelegramSourceAllowlist() { + const envList = parseTelegramSourceAllowlist(process.env.TELEGRAM_ALLOWED_SOURCES || process.env.TELEGRAM_SOURCE_ALLOWLIST || ""); + if (envList.length > 0) return envList; + const stored = await getSetting("telegram_allowed_sources", ""); + return parseTelegramSourceAllowlist(stored || ""); +} +async function assertTelegramSourceAllowed(source) { + const normalized = normalizeSource(source).toLowerCase(); + const allowlist = await getTelegramSourceAllowlist(); + if (allowlist.length === 0) { + if (/^-?\d+$/.test(normalized)) { + throw new Error("\u672A\u914D\u7F6E Telegram \u6765\u6E90\u767D\u540D\u5355\uFF0C\u7981\u6B62\u4F7F\u7528\u6570\u5B57 ID/\u79C1\u804A/\u79C1\u5BC6\u7FA4\u7EC4\u6765\u6E90\u3002\u8BF7\u914D\u7F6E TELEGRAM_ALLOWED_SOURCES\u3002"); + } + return; + } + if (!allowlist.includes(normalized)) { + throw new Error(`\u6765\u6E90 ${source} \u4E0D\u5728 Telegram \u4E0B\u8F7D\u767D\u540D\u5355\u4E2D`); + } +} +function maxProcessedMessageId(result) { + return Math.max(0, ...result.successfulMessageIds, ...result.skippedMessageIds); +} +function requireUserClient() { + const userClient2 = getTelegramUserClient(); + if (!userClient2 || !isTelegramUserClientReady()) { + throw new Error("Telegram \u7528\u6237\u8D26\u53F7\u4E0B\u8F7D\u5668\u672A\u5C31\u7EEA"); + } + return userClient2; +} +function normalizeSource(source) { + const trimmed = source.trim(); + if (!trimmed) throw new Error("\u9891\u9053\u4E0D\u80FD\u4E3A\u7A7A"); + if (trimmed.startsWith("@") || /^-?\d+$/.test(trimmed) || /^https?:\/\//i.test(trimmed)) return trimmed; + return `@${trimmed}`; +} +function getEntityTitle(entity, fallback) { + return entity?.title || [entity?.firstName, entity?.lastName].filter(Boolean).join(" ") || entity?.username || fallback; +} +function messageHasMedia(message) { + if (!message) return false; + return Boolean(message.media || message.document || message.photo || message.video || message.audio || message.voice || message.sticker); +} +function normalizeHashtag(tagInput) { + const trimmed = tagInput.trim(); + if (!trimmed) throw new Error("\u6807\u7B7E\u4E0D\u80FD\u4E3A\u7A7A"); + const withoutHash = trimmed.replace(/^#+/, ""); + if (!withoutHash || /\s/.test(withoutHash)) throw new Error("\u6807\u7B7E\u683C\u5F0F\u5E94\u4E3A #xxx\uFF0C\u4E0D\u80FD\u5305\u542B\u7A7A\u683C"); + return `#${withoutHash}`; +} +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +function messageTextForTag(message) { + if (!message) return ""; + return [message.message, message.text, message.caption].filter(Boolean).join("\n"); +} +function messageMatchesHashtag(message, normalizedTag) { + const body = messageTextForTag(message); + if (!body) return false; + const tag = escapeRegExp(normalizedTag.slice(1)); + const pattern = new RegExp(`(^|[^\\p{L}\\p{N}_])#${tag}(?![\\p{L}\\p{N}_])`, "iu"); + return pattern.test(body); +} +async function getLatestMessageId(userClient2, source) { + const [latest] = await userClient2.getMessages(source, { limit: 1 }); + return latest?.id || 0; +} +async function getMessagesByDateRange(userClient2, source, startDate, endDate, maxScan = 5e3) { + const result = []; + let offsetId = 0; + while (result.length < maxScan) { + const batch = await userClient2.getMessages(source, { limit: Math.min(100, maxScan - result.length), offsetId }); + if (!batch.length) break; + let reachedOlder = false; + for (const message of batch) { + offsetId = message.id; + const messageDate = new Date((message.date || 0) * 1e3); + if (messageDate > endDate) continue; + if (messageDate < startDate) { + reachedOlder = true; + break; + } + if (messageHasMedia(message)) result.push(message); + } + if (reachedOlder) break; + } + return result.sort((a, b) => a.id - b.id); +} +function messageGroupId(message) { + const groupedId = message?.groupedId; + return groupedId ? groupedId.toString() : void 0; +} +async function expandMessagesWithMediaGroups(userClient2, source, messages) { + const byId = /* @__PURE__ */ new Map(); + const seenGroups = /* @__PURE__ */ new Set(); + for (const message of messages) { + if (messageHasMedia(message)) byId.set(message.id, message); + const groupId = messageGroupId(message); + if (!groupId || seenGroups.has(groupId)) continue; + seenGroups.add(groupId); + const ids = Array.from({ length: 41 }, (_, index) => message.id - 20 + index).filter((id) => id > 0); + const nearby = await userClient2.getMessages(source, { ids }); + for (const candidate of nearby) { + if (candidate && messageHasMedia(candidate) && messageGroupId(candidate) === groupId) { + byId.set(candidate.id, candidate); + } + } + } + return Array.from(byId.values()).sort((a, b) => a.id - b.id); +} +async function persistDownloadItems(jobId, source, messages) { + for (const message of messages) { + await query( + `INSERT INTO telegram_download_items (job_id, source, message_id, grouped_id, status) + VALUES ($1, $2, $3, $4, 'pending') + ON CONFLICT (job_id, message_id) DO NOTHING`, + [jobId, source, message.id, messageGroupId(message) || null] + ); + } +} +async function updateDownloadItemsStatus(jobId, messageIds, status, error) { + const ids = Array.from(new Set((messageIds || []).filter((id) => id > 0))); + if (ids.length === 0) return; + await query( + `UPDATE telegram_download_items + SET status = $2, error = $3, updated_at = NOW() + WHERE job_id = $1 AND message_id = ANY($4::int[])`, + [jobId, status, error || null, ids] + ); +} +function parseDateOnly(value, endOfDay = false) { + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) throw new Error("\u65E5\u671F\u683C\u5F0F\u5FC5\u987B\u662F YYYY-MM-DD"); + const [, year, month, day] = match; + return /* @__PURE__ */ new Date(`${year}-${month}-${day}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z`); +} +async function createJob(userId, chatId, kind, source, params) { + const result = await query( + `INSERT INTO telegram_background_jobs (user_id, chat_id, kind, source, params) + VALUES ($1, $2, $3, $4, $5) + RETURNING id`, + [userId, chatId || null, kind, source, JSON.stringify(params)] + ); + return result.rows[0].id; +} +async function updateJob(jobId, updates) { + const entries = Object.entries(updates); + if (entries.length === 0) return; + const setSql = entries.map(([key], index) => `${key} = $${index + 2}`).join(", "); + await query(`UPDATE telegram_background_jobs SET ${setSql}, updated_at = NOW() WHERE id = $1`, [jobId, ...entries.map(([, value]) => value)]); +} +async function subscribeTelegramChannel(userId, chatId, sourceInput, folderOverride) { + const userClient2 = requireUserClient(); + const source = normalizeSource(sourceInput); + await assertTelegramSourceAllowed(source); + const entity = await userClient2.getEntity(source); + const latestMessageId = await getLatestMessageId(userClient2, source); + const title = getEntityTitle(entity, source); + const result = await query( + `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, 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]; +} +async function listTelegramSubscriptions(userId, includeDisabled = false) { + const result = await query( + `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) + ORDER BY updated_at DESC`, + [userId, includeDisabled] + ); + return result.rows; +} +async function updateTelegramSubscriptionFolder(userId, selector, folderOverride) { + 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; +} +async function unsubscribeTelegramChannel(userId, selector) { + const trimmed = selector.trim(); + const normalizedSelector = /^@|^https?:\/\//i.test(trimmed) || /^-?\d+$/.test(trimmed) ? normalizeSource(trimmed) : trimmed; + const result = await query( + `UPDATE telegram_channel_subscriptions + SET enabled = false, updated_at = NOW() + WHERE user_id = $1 AND (source = $2 OR id::text LIKE $3) + RETURNING source, title`, + [userId, normalizedSelector, `${trimmed}%`] + ); + return result.rows[0] || null; +} +async function enqueueTelegramDateDownload(botClient, requestMessage, userId, sourceInput, startDateText, endDateText, folderOverride) { + const userClient2 = requireUserClient(); + const source = normalizeSource(sourceInput); + await assertTelegramSourceAllowed(source); + const startDate = parseDateOnly(startDateText); + const endDate = parseDateOnly(endDateText, true); + if (startDate > endDate) throw new Error("\u5F00\u59CB\u65E5\u671F\u4E0D\u80FD\u665A\u4E8E\u7ED3\u675F\u65E5\u671F"); + const baseMessages = await getMessagesByDateRange(userClient2, source, startDate, endDate); + const messages = await expandMessagesWithMediaGroups(userClient2, 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, folderOverride: folderOverride || null }); + await persistDownloadItems(jobId, source, messages); + await updateJob(jobId, { status: "running", started_at: /* @__PURE__ */ new Date(), total_count: messages.length }); + try { + 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", "\u4E0B\u8F7D\u5931\u8D25"); + await updateDownloadItemsStatus(jobId, result.skippedMessageIds, "skipped"); + await updateJob(jobId, { + status: result.failed > 0 ? "completed_with_errors" : "completed", + enqueued_count: result.found, + skipped_count: result.skipped, + error: result.failed > 0 ? `${result.failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null, + finished_at: /* @__PURE__ */ new Date() + }); + return { jobId, ...result }; + } catch (error) { + await updateJob(jobId, { status: "failed", error: error instanceof Error ? error.message : String(error), finished_at: /* @__PURE__ */ new Date() }); + throw error; + } +} +async function getMessagesByHashtag(userClient2, source, tag, maxScan = 1e4) { + const normalizedTag = normalizeHashtag(tag); + const result = []; + let offsetId = 0; + while (result.length < maxScan) { + const batch = await userClient2.getMessages(source, { + limit: Math.min(100, maxScan - result.length), + offsetId, + search: normalizedTag + }); + if (!batch.length) break; + for (const message of batch) { + offsetId = message.id; + if (messageHasMedia(message) && messageMatchesHashtag(message, normalizedTag)) { + result.push(message); + } + } + } + return result.sort((a, b) => a.id - b.id); +} +async function enqueueTelegramTagDownload(botClient, requestMessage, userId, sourceInput, tagInput, folderOverride) { + const userClient2 = requireUserClient(); + const source = normalizeSource(sourceInput); + await assertTelegramSourceAllowed(source); + const tag = normalizeHashtag(tagInput); + const baseMessages = await getMessagesByHashtag(userClient2, source, tag); + const messages = await expandMessagesWithMediaGroups(userClient2, source, baseMessages); + const messageIds = messages.map((message) => message.id); + 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: /* @__PURE__ */ new Date(), total_count: messages.length }); + try { + 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", "\u4E0B\u8F7D\u5931\u8D25"); + await updateDownloadItemsStatus(jobId, result.skippedMessageIds, "skipped"); + await updateJob(jobId, { + status: result.failed > 0 ? "completed_with_errors" : "completed", + enqueued_count: result.found, + skipped_count: result.skipped, + error: result.failed > 0 ? `${result.failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null, + finished_at: /* @__PURE__ */ new Date() + }); + return { jobId, tag, ...result }; + } catch (error) { + await updateJob(jobId, { status: "failed", error: error instanceof Error ? error.message : String(error), finished_at: /* @__PURE__ */ new Date() }); + throw error; + } +} +async function listTelegramBackgroundJobs(userId, limit = 10) { + const result = await query( + `SELECT id, kind, source, status, total_count, enqueued_count, skipped_count, duplicate_count, error, created_at, updated_at + FROM telegram_background_jobs + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2`, + [userId, limit] + ); + return result.rows; +} +async function runSubscriptionScan(botClient) { + const userClient2 = getTelegramUserClient(); + if (!userClient2 || !isTelegramUserClientReady()) return; + const result = await query( + `SELECT id, user_id, chat_id, source, last_message_id, folder_override + FROM telegram_channel_subscriptions + WHERE enabled = true + ORDER BY updated_at ASC` + ); + for (const row of result.rows) { + try { + await assertTelegramSourceAllowed(row.source); + const latestMessageId = await getLatestMessageId(userClient2, row.source); + const lastMessageId = Number(row.last_message_id || 0); + if (!latestMessageId || latestMessageId <= lastMessageId) continue; + const count = Math.min(SUBSCRIPTION_SCAN_LIMIT, latestMessageId - lastMessageId); + const ids = Array.from({ length: count }, (_, index) => lastMessageId + index + 1); + const jobId = await createJob(Number(row.user_id), row.chat_id?.toString(), "subscription_sync", row.source, { fromId: lastMessageId + 1, toId: latestMessageId }); + const candidateMessages = await expandMessagesWithMediaGroups(userClient2, row.source, (await userClient2.getMessages(row.source, { ids })).filter(Boolean)); + await persistDownloadItems(jobId, row.source, candidateMessages); + await updateJob(jobId, { status: "running", started_at: /* @__PURE__ */ new Date(), total_count: ids.length }); + const targetChat = row.chat_id || row.user_id; + const requestMessage = { chatId: targetChat, id: latestMessageId }; + 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", "\u4E0B\u8F7D\u5931\u8D25"); + await updateDownloadItemsStatus(jobId, downloadResult.skippedMessageIds, "skipped"); + await updateJob(jobId, { + status: downloadResult.failed > 0 ? "completed_with_errors" : "completed", + enqueued_count: downloadResult.found, + skipped_count: downloadResult.skipped, + error: downloadResult.failed > 0 ? `${downloadResult.failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null, + finished_at: /* @__PURE__ */ new Date() + }); + 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: `\u2705 \u8BA2\u9605 ${row.source} \u5DF2\u540C\u6B65 ${downloadResult.found} \u4E2A\u65B0\u6587\u4EF6\uFF0C\u8DF3\u8FC7 ${downloadResult.skipped} \u6761${downloadResult.failed ? `\uFF0C\u5931\u8D25 ${downloadResult.failed} \u6761` : ""}${safeAdvanceId < latestMessageId ? "\u3002\u672C\u8F6E\u8FBE\u5230\u626B\u63CF\u4E0A\u9650\u6216\u5B58\u5728\u5931\u8D25\u9879\uFF0C\u5269\u4F59\u5C06\u5728\u540E\u7EED\u7EE7\u7EED\u5904\u7406\u3002" : "\u3002"}` }).catch(() => void 0); + } + } catch (error) { + console.error("\u{1F916} Telegram \u8BA2\u9605\u540C\u6B65\u5931\u8D25:", error); + } + } +} +function startTelegramSubscriptionWorker(botClient) { + if (subscriptionTimer) return; + subscriptionTimer = setInterval(() => { + runSubscriptionScan(botClient).catch((error) => console.error("\u{1F916} Telegram \u8BA2\u9605\u626B\u63CF\u5F02\u5E38:", error)); + }, SUBSCRIPTION_INTERVAL_MS); + runSubscriptionScan(botClient).catch((error) => console.error("\u{1F916} Telegram \u8BA2\u9605\u626B\u63CF\u5F02\u5E38:", error)); + console.log(`\u{1F916} Telegram \u9891\u9053\u8BA2\u9605\u626B\u63CF\u5DF2\u542F\u52A8\uFF0C\u95F4\u9694 ${Math.round(SUBSCRIPTION_INTERVAL_MS / 1e3)} \u79D2`); +} // src/services/orphanCleanup.ts init_db(); @@ -4937,11 +5637,75 @@ function stopPeriodicCleanup() { // src/services/telegramCommands.ts init_localPath(); + +// src/utils/fileScope.ts +init_db(); +init_localPath(); +import path12 from "path"; +var CLOUD_SOURCES = /* @__PURE__ */ new Set(["onedrive", "aliyun_oss", "s3", "webdav", "google_drive"]); +var UPLOAD_DIR3 = path12.resolve(process.env.UPLOAD_DIR || "./data/uploads"); +var THUMBNAIL_DIR3 = path12.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); +async function getCurrentStorageScope() { + const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); + const provider = storageManager2.getProvider(); + if (provider.name === "local") { + return { clause: "source = 'local'", params: [] }; + } + return { clause: "storage_account_id = $1", params: [storageManager2.getActiveAccountId()] }; +} +function nextParam(scope, offset) { + return `$${scope.params.length + offset}`; +} +async function getScopedFileById(id) { + 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; +} +async function removePhysicalFile(file) { + if (CLOUD_SOURCES.has(file.source)) { + const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); + const provider = storageManager2.getProvider(`${file.source}:${file.storage_account_id}`); + await provider.deleteFile(file.path); + } else { + const filePath = file.path || path12.join(UPLOAD_DIR3, file.stored_name); + await safeUnlink(filePath, UPLOAD_DIR3); + } + if (file.thumbnail_path) { + const thumbPath = path12.join(THUMBNAIL_DIR3, path12.basename(file.thumbnail_path)); + await safeUnlink(thumbPath, THUMBNAIL_DIR3); + } +} +async function updateScopedFileById(id, setSql, values) { + 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; +} + +// src/services/telegramCommands.ts var checkDiskSpace = checkDiskSpaceModule.default || checkDiskSpaceModule; var DOWNLOAD_WORKER_OPTIONS = [4, 8, 12, 16]; var ON_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]); -var UPLOAD_DIR3 = process.env.UPLOAD_DIR || "./data/uploads"; -var THUMBNAIL_DIR3 = process.env.THUMBNAIL_DIR || "./data/thumbnails"; +var UPLOAD_DIR4 = process.env.UPLOAD_DIR || "./data/uploads"; +var THUMBNAIL_DIR4 = process.env.THUMBNAIL_DIR || "./data/thumbnails"; +var pendingDeleteConfirmations = /* @__PURE__ */ new Map(); +var DELETE_CONFIRM_TTL_MS = 5 * 60 * 1e3; +function buildDeleteConfirmKeyboard(confirmId) { + return new Api4.ReplyInlineMarkup({ + rows: [new Api4.KeyboardButtonRow({ + buttons: [ + new Api4.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u5220\u9664", data: Buffer.from(`del_confirm_${confirmId}`) }), + new Api4.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from(`del_cancel_${confirmId}`) }) + ] + })] + }); +} function normalizeDownloadWorkers(value) { const parsed = parseInt(String(value ?? "4"), 10); return DOWNLOAD_WORKER_OPTIONS.includes(parsed) ? parsed : 4; @@ -4952,34 +5716,78 @@ async function getCurrentDownloadWorkers() { } function buildDownloadWorkersKeyboard(current, confirmValue) { if (confirmValue) { - return new Api3.ReplyInlineMarkup({ + return new Api4.ReplyInlineMarkup({ rows: [ - new Api3.KeyboardButtonRow({ + new Api4.KeyboardButtonRow({ buttons: [ - new Api3.KeyboardButtonCallback({ text: `\u26A0\uFE0F \u786E\u8BA4\u4F7F\u7528 ${confirmValue}`, data: Buffer.from(`dw_confirm_${confirmValue}`) }), - new Api3.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("dw_cancel") }) + new Api4.KeyboardButtonCallback({ text: `\u26A0\uFE0F \u786E\u8BA4\u4F7F\u7528 ${confirmValue}`, data: Buffer.from(`dw_confirm_${confirmValue}`) }), + new Api4.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("dw_cancel") }) ] }) ] }); } - return new Api3.ReplyInlineMarkup({ + return new Api4.ReplyInlineMarkup({ rows: [ - new Api3.KeyboardButtonRow({ + new Api4.KeyboardButtonRow({ buttons: [ - new Api3.KeyboardButtonCallback({ text: `${current === 4 ? "\u2705 " : ""}4`, data: Buffer.from("dw_set_4") }), - new Api3.KeyboardButtonCallback({ text: `${current === 8 ? "\u2705 " : ""}8`, data: Buffer.from("dw_set_8") }) + new Api4.KeyboardButtonCallback({ text: `${current === 4 ? "\u2705 " : ""}4`, data: Buffer.from("dw_set_4") }), + new Api4.KeyboardButtonCallback({ text: `${current === 8 ? "\u2705 " : ""}8`, data: Buffer.from("dw_set_8") }) ] }), - new Api3.KeyboardButtonRow({ + new Api4.KeyboardButtonRow({ buttons: [ - new Api3.KeyboardButtonCallback({ text: `${current === 12 ? "\u2705 " : ""}12 \u26A0\uFE0F`, data: Buffer.from("dw_set_12") }), - new Api3.KeyboardButtonCallback({ text: `${current === 16 ? "\u2705 " : ""}16 \u26A0\uFE0F`, data: Buffer.from("dw_set_16") }) + new Api4.KeyboardButtonCallback({ text: `${current === 12 ? "\u2705 " : ""}12 \u26A0\uFE0F`, data: Buffer.from("dw_set_12") }), + new Api4.KeyboardButtonCallback({ text: `${current === 16 ? "\u2705 " : ""}16 \u26A0\uFE0F`, data: Buffer.from("dw_set_16") }) ] }) ] }); } +function buildStorageMaintenanceKeyboard(localFileCount, confirm = false) { + if (localFileCount <= 0) return void 0; + return new Api4.ReplyInlineMarkup({ + rows: [ + new Api4.KeyboardButtonRow({ + buttons: confirm ? [ + new Api4.KeyboardButtonCallback({ text: "\u26A0\uFE0F \u786E\u8BA4\u5220\u9664\u672C\u5730\u5168\u90E8\u4E0B\u8F7D\u6587\u4EF6", data: Buffer.from("storage_clear_confirm") }), + new Api4.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("storage_clear_cancel") }) + ] : [ + new Api4.KeyboardButtonCallback({ text: `\u{1F9F9} \u5220\u9664\u672C\u5730\u5168\u90E8\u4E0B\u8F7D\u6587\u4EF6 (${localFileCount})`, data: Buffer.from("storage_clear_ask") }) + ] + }) + ] + }); +} +async function scanLocalDownloadFiles() { + const baseDir = path13.resolve(UPLOAD_DIR4); + const paths = []; + let totalSize = 0; + if (!fs9.existsSync(baseDir)) return { count: 0, totalSize: 0, paths }; + async function walk(dir) { + const entries = await fs9.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path13.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.isFile()) { + const stat = await fs9.promises.stat(fullPath); + totalSize += stat.size; + paths.push(fullPath); + } + } + } + await walk(baseDir); + return { count: paths.length, totalSize, paths }; +} +async function pruneEmptyDirs(dir, baseDir = path13.resolve(UPLOAD_DIR4)) { + if (!fs9.existsSync(dir) || path13.resolve(dir) === baseDir) return; + const entries = await fs9.promises.readdir(dir); + if (entries.length === 0) { + await fs9.promises.rmdir(dir); + await pruneEmptyDirs(path13.dirname(dir), baseDir); + } +} function buildDownloadWorkersText(current) { return [ "\u2699\uFE0F **Telegram \u5E76\u53D1\u4E0B\u8F7D\u8BBE\u7F6E**", @@ -4998,43 +5806,16 @@ function isOn(value, defaultValue = true) { if (value === void 0 || value === null || value === "") return defaultValue; return ON_VALUES.has(String(value).toLowerCase()); } -async function getPathRuleSettings() { - 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) { - return new Api3.ReplyInlineMarkup({ - rows: [ - new Api3.KeyboardButtonRow({ - buttons: [ - new Api3.KeyboardButtonCallback({ text: `${rules.bySource ? "\u2705" : "\u2B1C"} \u6309\u6765\u6E90/\u9891\u9053`, data: Buffer.from("pr_toggle_source") }), - new Api3.KeyboardButtonCallback({ text: `${rules.byType ? "\u2705" : "\u2B1C"} \u6309\u7C7B\u578B`, data: Buffer.from("pr_toggle_type") }) - ] - }) - ] - }); -} -function buildPathRulesText(rules) { - const example = [rules.bySource ? "telegram/\u8D44\u6E90\u4E0B\u8F7D" : null, rules.byType ? "archives" : null].filter(Boolean).join("/") || "\u6587\u4EF6\u540D\u76F4\u63A5\u4FDD\u5B58"; - return [ - "\u{1F4C1} **\u4FDD\u5B58\u8DEF\u5F84\u89C4\u5219**", - "", - `\u6309\u6765\u6E90/\u9891\u9053\uFF1A${rules.bySource ? "\u2705 \u5F00\u542F" : "\u2B1C \u5173\u95ED"}`, - `\u6309\u6587\u4EF6\u7C7B\u578B\uFF1A${rules.byType ? "\u2705 \u5F00\u542F" : "\u2B1C \u5173\u95ED"}`, - "", - `\u793A\u4F8B\uFF1A${example}`, - "", - "\u8BF4\u660E\uFF1A\u4FEE\u6539\u540E\u53EA\u5F71\u54CD\u540E\u7EED\u65B0\u4E0A\u4F20/\u8F6C\u5B58\u6587\u4EF6\u3002" - ].join("\n"); +async function getPathCenterState() { + return { automaticBySource: true, automaticByType: true }; } function buildDuplicateModeKeyboard(mode) { - return new Api3.ReplyInlineMarkup({ + return new Api4.ReplyInlineMarkup({ rows: [ - new Api3.KeyboardButtonRow({ + new Api4.KeyboardButtonRow({ buttons: [ - new Api3.KeyboardButtonCallback({ text: `${mode === "skip" ? "\u2705" : "\u2B1C"} \u8DF3\u8FC7\u91CD\u590D`, data: Buffer.from("dm_set_skip") }), - new Api3.KeyboardButtonCallback({ text: `${mode === "copy" ? "\u2705" : "\u2B1C"} \u751F\u6210\u526F\u672C`, data: Buffer.from("dm_set_copy") }) + new Api4.KeyboardButtonCallback({ text: `${mode === "skip" ? "\u2705" : "\u2B1C"} \u8DF3\u8FC7\u91CD\u590D`, data: Buffer.from("dm_set_skip") }), + new Api4.KeyboardButtonCallback({ text: `${mode === "copy" ? "\u2705" : "\u2B1C"} \u751F\u6210\u526F\u672C`, data: Buffer.from("dm_set_copy") }) ] }) ] @@ -5057,12 +5838,12 @@ async function getCleanupEnabledSetting() { return isOn(value, true); } function buildCleanupSettingsKeyboard(enabled) { - return new Api3.ReplyInlineMarkup({ + return new Api4.ReplyInlineMarkup({ rows: [ - new Api3.KeyboardButtonRow({ + new Api4.KeyboardButtonRow({ buttons: [ - new Api3.KeyboardButtonCallback({ text: `${!enabled ? "\u2705" : "\u2B1C"} \u5173\u95ED\u81EA\u52A8\u6E05\u7406`, data: Buffer.from("cs_set_off") }), - new Api3.KeyboardButtonCallback({ text: `${enabled ? "\u2705" : "\u2B1C"} \u5F00\u542F\u81EA\u52A8\u6E05\u7406`, data: Buffer.from("cs_set_on") }) + new Api4.KeyboardButtonCallback({ text: `${!enabled ? "\u2705" : "\u2B1C"} \u5173\u95ED\u81EA\u52A8\u6E05\u7406`, data: Buffer.from("cs_set_off") }), + new Api4.KeyboardButtonCallback({ text: `${enabled ? "\u2705" : "\u2B1C"} \u5F00\u542F\u81EA\u52A8\u6E05\u7406`, data: Buffer.from("cs_set_on") }) ] }) ] @@ -5080,8 +5861,15 @@ function buildCleanupSettingsText(enabled) { "\u8BF4\u660E\uFF1A\u53EA\u5F71\u54CD\u672C\u5730 uploads \u5B64\u513F\u6587\u4EF6\uFF0C\u4E0D\u4F1A\u4E3B\u52A8\u6E05\u7406\u7B2C\u4E09\u65B9\u4E91\u5B58\u50A8\u3002" ].join("\n"); } +function getCallbackChatKey(update) { + const peer = update.peer; + const value = peer?.userId || peer?.chatId || peer?.channelId || update.userId; + if (value && typeof value.toJSNumber === "function") return String(value.toJSNumber()); + if (value !== void 0 && value !== null) return String(value); + return String(update.userId.toJSNumber()); +} async function handleStart(message, senderId) { - if (isAuthenticated(senderId)) { + if (await isAuthenticatedAsync(senderId)) { await message.reply({ message: buildWelcomeBack() }); } else { passwordInputState.set(senderId, { password: "" }); @@ -5092,139 +5880,228 @@ async function handleHelp(message) { } async function handleStorage(message) { try { - const activeAccountId = storageManager.getActiveAccountId(); + const scope = await getCurrentStorageScope(); const diskPath = os.platform() === "win32" ? "C:" : "/"; const diskSpace = await checkDiskSpace(diskPath); 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, diskFree: diskSpace.free, 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("\u{1F916} \u83B7\u53D6\u5B58\u50A8\u7EDF\u8BA1\u5931\u8D25:", error); await message.reply({ message: MSG.ERR_STORAGE }); } } -async function handleList(message, args) { +async function handleStorageCleanupCallback(client2, update, data) { + const userId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(userId)) { + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + return; + } try { - let limit = 10; - let page = 1; - if (args.length > 0) { - const parsed = parseInt(args[0]); - if (!isNaN(parsed) && parsed > 0 && parsed <= 50) { - limit = parsed; - } - } - if (args.length > 1) { - const parsedPage = parseInt(args[1]); - if (!isNaN(parsedPage) && parsedPage > 0) { - page = parsedPage; - } - } - const activeAccountId = storageManager.getActiveAccountId(); - 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]); - if (result.rows.length === 0) { - await message.reply({ message: MSG.EMPTY_FILES }); + const stats = await scanLocalDownloadFiles(); + if (data === "storage_clear_cancel") { + await client2.editMessage(update.peer, { + message: update.msgId, + text: stats.count > 0 ? `\u5DF2\u53D6\u6D88\u6E05\u7406\u3002\u5F53\u524D\u672C\u5730\u4E0B\u8F7D\u6587\u4EF6\uFF1A${stats.count} \u4E2A\uFF0C\u5360\u7528 ${formatBytes(stats.totalSize)}\u3002` : "\u5DF2\u53D6\u6D88\u6E05\u7406\u3002\u5F53\u524D\u6CA1\u6709\u672C\u5730\u4E0B\u8F7D\u6587\u4EF6\u3002", + buttons: buildStorageMaintenanceKeyboard(stats.count) + }); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; } - const reply = buildFileList(result.rows, result.rows.length); - await message.reply({ message: reply }); + if (data === "storage_clear_ask") { + await client2.editMessage(update.peer, { + message: update.msgId, + text: [ + "\u26A0\uFE0F **\u786E\u8BA4\u5220\u9664\u672C\u5730\u670D\u52A1\u5668\u5168\u90E8\u4E0B\u8F7D\u6587\u4EF6\uFF1F**", + "", + `\u5C06\u5220\u9664 uploads \u672C\u5730\u76EE\u5F55\u4E2D\u7684 **${stats.count}** \u4E2A\u6587\u4EF6\uFF0C\u5360\u7528 **${formatBytes(stats.totalSize)}**\u3002`, + "\u8FD9\u53EA\u6E05\u7406\u670D\u52A1\u5668\u672C\u5730\u4E0B\u8F7D/\u7F13\u5B58\u6587\u4EF6\uFF0C\u4E0D\u4F1A\u4E3B\u52A8\u5220\u9664 OneDrive \u7B49\u4E91\u7AEF\u5B58\u50A8\u91CC\u7684\u6587\u4EF6\u8BB0\u5F55\u3002", + "", + "\u5982\u786E\u8BA4\uFF0C\u8BF7\u70B9\u51FB\u4E0B\u65B9\u7EA2\u8272\u786E\u8BA4\u6309\u94AE\u3002" + ].join("\n"), + buttons: buildStorageMaintenanceKeyboard(stats.count, true) + }); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); + return; + } + if (data === "storage_clear_confirm") { + let deletedCount = 0; + let deletedBytes = 0; + for (const filePath of stats.paths) { + const size = fs9.existsSync(filePath) ? fs9.statSync(filePath).size : 0; + if (await safeUnlink(filePath, UPLOAD_DIR4)) { + deletedCount += 1; + deletedBytes += size; + await pruneEmptyDirs(path13.dirname(filePath)); + } + } + const after = await scanLocalDownloadFiles(); + await client2.editMessage(update.peer, { + message: update.msgId, + text: [ + "\u2705 **\u672C\u5730\u670D\u52A1\u5668\u4E0B\u8F7D\u6587\u4EF6\u5DF2\u6E05\u7406**", + "", + `\u5DF2\u5220\u9664\uFF1A${deletedCount} \u4E2A\u6587\u4EF6`, + `\u91CA\u653E\u7A7A\u95F4\uFF1A${formatBytes(deletedBytes)}`, + `\u5269\u4F59\u672C\u5730\u6587\u4EF6\uFF1A${after.count} \u4E2A` + ].join("\n"), + buttons: buildStorageMaintenanceKeyboard(after.count) + }); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u5220\u9664 ${deletedCount} \u4E2A\u6587\u4EF6` })); + } } catch (error) { - console.error("\u{1F916} \u83B7\u53D6\u6587\u4EF6\u5217\u8868\u5931\u8D25:", error); - await message.reply({ message: MSG.ERR_FILE_LIST }); + console.error("\u{1F916} \u6E05\u7406\u672C\u5730\u4E0B\u8F7D\u6587\u4EF6\u5931\u8D25:", error); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u6E05\u7406\u5931\u8D25: ${error.message}`, alert: true })); } } async function handleDelete(message, args) { if (args.length === 0) { await message.reply({ - message: "\u274C \u8BF7\u63D0\u4F9B\u8981\u5220\u9664\u7684\u6587\u4EF6\n\n\u7528\u6CD5\uFF1A\n/delete <\u5217\u8868\u5E8F\u53F7> \u4F8B\u5982 /delete 1\n/delete \u4F8B\u5982 /delete a1b2c3d4\n\n\u63D0\u793A\uFF1A\u5148\u53D1\u9001 /list \u67E5\u770B\u6587\u4EF6 ID \u548C\u5E8F\u53F7\u3002" + message: "\u274C \u8BF7\u63D0\u4F9B\u8981\u5220\u9664\u7684\u6587\u4EF6 ID \u524D\u7F00\n\n\u7528\u6CD5\uFF1A/delete <\u81F3\u5C11 8 \u4F4D ID \u524D\u7F00>\n\u63D0\u793A\uFF1A\u8BF7\u4ECE\u7F51\u9875\u7AEF\u6587\u4EF6\u5217\u8868\u590D\u5236\u6587\u4EF6 ID\u3002" }); return; } 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: "\u274C ID \u524D\u7F00\u81F3\u5C11\u9700\u8981 4 \u4F4D\u3002\u4E5F\u53EF\u4EE5\u7528 /delete 1 \u5220\u9664 /list \u4E2D\u7B2C 1 \u4E2A\u6587\u4EF6\u3002" }); - 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: "\u274C \u4E3A\u907F\u514D\u8BEF\u5220\uFF0CTelegram Bot \u4E0D\u518D\u652F\u6301\u6309\u5217\u8868\u5E8F\u53F7\u5220\u9664\u3002\u8BF7\u4ECE\u7F51\u9875\u7AEF\u590D\u5236\u81F3\u5C11 8 \u4F4D\u6587\u4EF6 ID \u524D\u7F00\u3002" }); + return; } + if (selector.length < 8) { + await message.reply({ message: "\u274C ID \u524D\u7F00\u81F3\u5C11\u9700\u8981 8 \u4F4D\u3002\u8BF7\u4ECE\u7F51\u9875\u7AEF\u6587\u4EF6\u5217\u8868\u590D\u5236\u66F4\u957F\u7684\u6587\u4EF6 ID\u3002" }); + 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) ? `\u274C \u672A\u627E\u5230\u5217\u8868\u5E8F\u53F7 ${selector} \u5BF9\u5E94\u7684\u6587\u4EF6` : `\u274C \u672A\u627E\u5230 ID \u4EE5 "${selector}" \u5F00\u5934\u7684\u6587\u4EF6` }); + await message.reply({ message: `\u274C \u672A\u627E\u5230 ID \u4EE5 "${selector}" \u5F00\u5934\u7684\u6587\u4EF6` }); + return; + } + if (result.rows.length > 1) { + await message.reply({ message: `\u274C ID \u524D\u7F00 "${selector}" \u5339\u914D\u5230\u591A\u4E2A\u6587\u4EF6\uFF0C\u8BF7\u590D\u5236\u66F4\u957F\u7684 ID \u524D\u7F00\u540E\u91CD\u8BD5\u3002` }); 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(`\u{1F916} ${file.source} \u6587\u4EF6\u7269\u7406\u5220\u9664\u5931\u8D25\u6216\u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728:`, err); - } - } else if (file.path) { - await safeUnlink(file.path, UPLOAD_DIR3); - } - if (file.thumbnail_path) { - await safeUnlink(file.thumbnail_path, THUMBNAIL_DIR3); - } - 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: [ + "\u26A0\uFE0F **\u786E\u8BA4\u5220\u9664\u8FD9\u4E2A\u6587\u4EF6\uFF1F**", + "", + `\u{1F4C4} ${file.name}`, + `\u{1F194} ${String(file.id).slice(0, 12)}`, + `\u{1F4E6} ${formatBytes(Number(file.size || 0))}`, + file.folder ? `\u{1F4C1} ${file.folder}` : "", + "", + "\u5220\u9664\u4F1A\u79FB\u9664\u6570\u636E\u5E93\u8BB0\u5F55\u5E76\u5C1D\u8BD5\u5220\u9664\u5B9E\u9645\u6587\u4EF6\u3002\u8BF7\u786E\u8BA4\u65E0\u8BEF\u540E\u70B9\u51FB\u6309\u94AE\u3002" + ].filter(Boolean).join("\n"), + buttons: buildDeleteConfirmKeyboard(confirmId) + }); } catch (error) { console.error("\u{1F916} \u5220\u9664\u6587\u4EF6\u5931\u8D25:", error); await message.reply({ message: `${MSG.ERR_DELETE}: ${error.message}` }); } } +async function handleDeleteConfirmCallback(client2, update, data) { + const userId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(userId)) { + await client2.invoke(new Api4.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 client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5220\u9664\u786E\u8BA4\u5DF2\u8FC7\u671F", alert: true })); + return; + } + if (action === "cancel") { + pendingDeleteConfirmations.delete(confirmId); + await client2.editMessage(update.peer, { message: update.msgId, text: `\u5DF2\u53D6\u6D88\u5220\u9664\uFF1A${pending.name}`, buttons: new Api4.ReplyInlineMarkup({ rows: [] }) }); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); + 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 client2.editMessage(update.peer, { message: update.msgId, text: "\u274C \u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728\u6216\u4E0D\u5728\u5F53\u524D\u5B58\u50A8\u8303\u56F4\u5185\u3002", buttons: new Api4.ReplyInlineMarkup({ rows: [] }) }); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u6587\u4EF6\u4E0D\u5B58\u5728", alert: true })); + return; + } + try { + await removePhysicalFile(file); + } catch (err) { + console.warn("\u{1F916} \u6587\u4EF6\u7269\u7406\u5220\u9664\u5931\u8D25\u6216\u6587\u4EF6\u5DF2\u4E0D\u5B58\u5728:", err); + } + await query("DELETE FROM files WHERE id = $1", [file.id]); + pendingDeleteConfirmations.delete(confirmId); + await client2.editMessage(update.peer, { message: update.msgId, text: buildDeleteSuccess(file.name, file.id), buttons: new Api4.ReplyInlineMarkup({ rows: [] }) }); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u5220\u9664" })); + } catch (error) { + console.error("\u{1F916} \u786E\u8BA4\u5220\u9664\u6587\u4EF6\u5931\u8D25:", error); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5220\u9664\u5931\u8D25: ${error.message}`, alert: true })); + } +} async function handleTasks(message) { try { const status = getTaskStatus(); const activeCount = status.active.length; const pendingCount = status.pending.length; const historyCount = status.history.length; - if (activeCount === 0 && pendingCount === 0 && historyCount === 0) { + const senderId = message.senderId?.toJSNumber(); + const jobs = senderId ? await listTelegramBackgroundJobs(senderId, 5) : []; + 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([ + "\u{1F4E1} **\u9891\u9053\u540E\u53F0\u4EFB\u52A1**", + ...jobs.map((job, index) => [ + `${index + 1}. ${job.kind} \xB7 ${job.status}`, + ` \u6765\u6E90\uFF1A${job.source}`, + ` \u8FDB\u5EA6\uFF1A${job.enqueued_count || 0}/${job.total_count || 0}\u3000\u8DF3\u8FC7\uFF1A${job.skipped_count || 0}`, + job.error ? ` \u26A0\uFE0F ${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("\u{1F916} \u83B7\u53D6\u4EFB\u52A1\u5217\u8868\u5931\u8D25:", error); await message.reply({ message: MSG.ERR_TASKS }); @@ -5253,8 +6130,8 @@ async function handleStopTasks(message) { async function handlePauseTasks(message, args = []) { const taskId = args[0]; const result = pauseDownloadTasks(taskId); - await message.reply({ message: `\u23F8\uFE0F \u5DF2\u6682\u505C\u4E0B\u8F7D\u961F\u5217${taskId ? ` -\u4EFB\u52A1: ${taskId}` : ""} + await message.reply({ message: `\u23F8\uFE0F \u5DF2\u6682\u505C\u5168\u5C40\u4E0B\u8F7D\u961F\u5217${taskId ? ` +\u4EFB\u52A1\u5361: ${taskId}` : ""} \u8FDB\u884C\u4E2D: ${result.active} \u7B49\u5F85\u4E2D: ${result.pending} @@ -5264,8 +6141,8 @@ async function handlePauseTasks(message, args = []) { async function handleResumeTasks(message, args = []) { const taskId = args[0]; const result = resumeDownloadTasks(taskId); - await message.reply({ message: `\u25B6\uFE0F \u5DF2\u7EE7\u7EED\u4E0B\u8F7D\u961F\u5217${taskId ? ` -\u4EFB\u52A1: ${taskId}` : ""} + await message.reply({ message: `\u25B6\uFE0F \u5DF2\u7EE7\u7EED\u5168\u5C40\u4E0B\u8F7D\u961F\u5217${taskId ? ` +\u4EFB\u52A1\u5361: ${taskId}` : ""} \u8FDB\u884C\u4E2D: ${result.active} \u7B49\u5F85\u4E2D: ${result.pending}` }); @@ -5273,11 +6150,11 @@ async function handleResumeTasks(message, args = []) { async function handleCancelTask(message, args) { const selector = args.join(" ").trim() || "all"; const result = cancelDownloadTask(selector); - await message.reply({ message: result.total > 0 ? `\u{1F6D1} \u5DF2\u53D6\u6D88\u4EFB\u52A1 + await message.reply({ message: result.total > 0 ? `\u{1F6D1} \u5DF2\u53D6\u6D88\u5339\u914D\u4EFB\u52A1 \u5339\u914D: ${selector} \u5904\u7406\u4E2D: ${result.active} -\u7B49\u5F85\u4E2D: ${result.pending}` : `\u{1F4EE} \u6CA1\u6709\u627E\u5230\u5339\u914D\u7684\u4EFB\u52A1\uFF1A${selector}` }); +\u7B49\u5F85\u4E2D: ${result.pending}` : `\u{1F4EE} \u6CA1\u6709\u627E\u5230\u5339\u914D\u7684\u4EFB\u52A1\uFF0C\u672A\u6E05\u7A7A\u5168\u5C40\u961F\u5217\uFF1A${selector}` }); } async function handleRetryFailedTasks(message, args) { const taskId = args.find((arg) => /^t[a-z0-9]+/i.test(arg)); @@ -5300,36 +6177,82 @@ async function handleDownloadWorkers(message) { } } async function handlePathRules(message) { - 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) }); } -async function handlePathRulesCallback(client2, update, data) { - const userId = update.userId.toJSNumber(); - if (!isAuthenticated(userId)) { - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); +async function handlePathOnce(message, args) { + const folder = args.join(" ").trim(); + if (!folder) { + await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/p <\u76EE\u5F55>\n\u4F8B\u5982\uFF1A/p PIXIV/\u6BCF\u65E5Top50" }); 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 normalized = await setNextTelegramPathPersistent(message.chatId?.toString() || "unknown", folder); + await message.reply({ message: `\u{1F4CC} \u5DF2\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u4E0B\u8F7D\u76EE\u5F55\uFF1A\`${normalized}\` +${buildPathPreviewLine(normalized)} + +\u6B64\u8BBE\u7F6E\u4F1A\u5728\u4E0B\u4E00\u6B21\u6210\u529F\u8FDB\u5165\u4E0B\u8F7D\u6D41\u7A0B\u65F6\u81EA\u52A8\u5931\u6548\u3002` }); + } catch (error) { + await message.reply({ message: `\u274C \u8DEF\u5F84\u65E0\u6548\uFF1A${error.message}` }); + } +} +async function handlePathSession(message, args) { + const folder = args.join(" ").trim(); + if (!folder) { + await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/ps <\u76EE\u5F55>\n\u4F8B\u5982\uFF1A/ps \u76F8\u518C/2026-07" }); + return; + } + try { + const normalized = await setSessionTelegramPathPersistent(message.chatId?.toString() || "unknown", folder); + await message.reply({ message: `\u{1F4CD} \u5DF2\u8BBE\u7F6E\u672C\u4F1A\u8BDD\u4E0B\u8F7D\u76EE\u5F55\uFF1A\`${normalized}\` +${buildPathPreviewLine(normalized)} + +\u540E\u7EED\u6B64\u804A\u5929\u4E2D\u7684\u4E0B\u8F7D\u4F1A\u4F18\u5148\u4FDD\u5B58\u5230\u8BE5\u76EE\u5F55\uFF0C\u53D1\u9001 /pc \u53EF\u6E05\u9664\u3002` }); + } catch (error) { + await message.reply({ message: `\u274C \u8DEF\u5F84\u65E0\u6548\uFF1A${error.message}` }); + } +} +async function handlePathClear(message) { + clearTelegramPathState(message.chatId?.toString() || "unknown"); + await message.reply({ message: "\u{1F9F9} \u5DF2\u6E05\u9664\u4E0B\u4E00\u6B21/\u672C\u4F1A\u8BDD\u81EA\u5B9A\u4E49\u4E0B\u8F7D\u76EE\u5F55\uFF0C\u540E\u7EED\u6062\u590D\u4F7F\u7528\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B\u76EE\u5F55\u3002" }); +} +async function handlePathRulesCallback(client2, update, data) { + const userId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(userId)) { + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + return; + } + try { + 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 client2.sendMessage(update.peer, { + message: recent.length > 0 ? ["\u{1F558} **\u6700\u8FD1\u4F7F\u7528\u76EE\u5F55**", "", ...recent.map((item, index) => `${index + 1}. ${item}`), "", "\u8981\u4F7F\u7528\u5176\u4E2D\u4E00\u4E2A\u76EE\u5F55\uFF0C\u8BF7\u76F4\u63A5\u590D\u5236\u53D1\u9001\uFF0C\u6216\u53D1\u9001 `/p <\u76EE\u5F55>` / `/ps <\u76EE\u5F55>`\u3002"].join("\n") : "\u{1F558} \u6682\u65E0\u6700\u8FD1\u4F7F\u7528\u76EE\u5F55\u3002\u8BBE\u7F6E\u8FC7 `/p`\u3001`/ps`\u3001\u8BA2\u9605\u4E13\u5C5E\u76EE\u5F55\u6216\u4E0B\u8F7D\u4EFB\u52A1\u4E13\u5C5E\u76EE\u5F55\u540E\u4F1A\u81EA\u52A8\u8BB0\u5F55\u3002" + }); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D1\u9001\u6700\u8FD1\u76EE\u5F55" })); + return; + } else if (data === "pr_help_once" || data === "pr_help_session") { + const mode = data === "pr_help_once" ? "once" : "session"; + setPendingTelegramPathInput(chatKey, userId, mode); + await client2.sendMessage(update.peer, { message: await buildPendingPathPromptPersistent(mode, chatKey) }); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u76F4\u63A5\u53D1\u9001\u76EE\u5F55\uFF0C\u6216\u53D1\u9001\u201C\u53D6\u6D88\u201D\u9000\u51FA" })); + return; } await client2.editMessage(update.peer, { message: update.msgId, - text: buildPathRulesText(rules), - buttons: buildPathRulesKeyboard(rules) + text: buildPathSettingsText(pathCenterState, chatKey), + buttons: buildPathSettingsKeyboard(pathCenterState) }); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4FDD\u5B58\u8DEF\u5F84\u89C4\u5219\u5DF2\u66F4\u65B0" })); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u4FDD\u5B58\u4F4D\u7F6E\u5DF2\u66F4\u65B0" })); } catch (error) { - console.error("\u{1F916} \u8BBE\u7F6E\u4FDD\u5B58\u8DEF\u5F84\u89C4\u5219\u5931\u8D25:", error); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true })); + console.error("\u{1F916} \u8BBE\u7F6E\u4FDD\u5B58\u4F4D\u7F6E\u5931\u8D25:", error); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true })); } } async function handleDuplicateMode(message) { @@ -5341,8 +6264,8 @@ async function handleDuplicateMode(message) { } async function handleDuplicateModeCallback(client2, update, data) { const userId = update.userId.toJSNumber(); - if (!isAuthenticated(userId)) { - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + if (!await isAuthenticatedAsync(userId)) { + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } try { @@ -5355,10 +6278,10 @@ async function handleDuplicateModeCallback(client2, update, data) { text: buildDuplicateModeText(mode), buttons: buildDuplicateModeKeyboard(mode) }); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A${mode === "skip" ? "\u8DF3\u8FC7\u91CD\u590D" : "\u751F\u6210\u526F\u672C"}` })); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A${mode === "skip" ? "\u8DF3\u8FC7\u91CD\u590D" : "\u751F\u6210\u526F\u672C"}` })); } catch (error) { console.error("\u{1F916} \u8BBE\u7F6E\u91CD\u590D\u6587\u4EF6\u5904\u7406\u5931\u8D25:", error); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true })); } } async function handleCleanupSettings(message) { @@ -5370,8 +6293,8 @@ async function handleCleanupSettings(message) { } async function handleCleanupSettingsCallback(client2, update, data) { const userId = update.userId.toJSNumber(); - if (!isAuthenticated(userId)) { - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + if (!await isAuthenticatedAsync(userId)) { + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); return; } try { @@ -5388,16 +6311,16 @@ async function handleCleanupSettingsCallback(client2, update, data) { text: buildCleanupSettingsText(enabled), buttons: buildCleanupSettingsKeyboard(enabled) }); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: enabled ? "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406" : "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406" })); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: enabled ? "\u5DF2\u5F00\u542F\u81EA\u52A8\u6E05\u7406" : "\u5DF2\u5173\u95ED\u81EA\u52A8\u6E05\u7406" })); } catch (error) { console.error("\u{1F916} \u8BBE\u7F6E\u81EA\u52A8\u6E05\u7406\u5931\u8D25:", error); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true })); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true })); } } async function handleDownloadWorkersCallback(client2, update, data) { const userId = update.userId.toJSNumber(); - if (!isAuthenticated(userId)) { - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ + if (!await isAuthenticatedAsync(userId)) { + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true @@ -5412,7 +6335,7 @@ async function handleDownloadWorkersCallback(client2, update, data) { text: buildDownloadWorkersText(current), buttons: buildDownloadWorkersKeyboard(current) }); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); return; } const setMatch = data.match(/^dw_set_(4|8|12|16)$/); @@ -5433,7 +6356,7 @@ async function handleDownloadWorkersCallback(client2, update, data) { ].join("\n"), buttons: buildDownloadWorkersKeyboard(workers, workers) }); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u9700\u8981\u4E8C\u6B21\u786E\u8BA4" })); return; } await setSetting("telegram_download_workers", String(workers)); @@ -5444,7 +6367,7 @@ async function handleDownloadWorkersCallback(client2, update, data) { \u2705 \u5DF2\u5207\u6362\u4E3A ${workers} workers\uFF0C\u540E\u7EED\u65B0\u4E0B\u8F7D\u4EFB\u52A1\u7ACB\u5373\u751F\u6548\u3002`, buttons: buildDownloadWorkersKeyboard(workers) }); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A ${workers}` })); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u8BBE\u7F6E\u4E3A ${workers}` })); return; } const confirmMatch = data.match(/^dw_confirm_(12|16)$/); @@ -5458,11 +6381,11 @@ async function handleDownloadWorkersCallback(client2, update, data) { \u26A0\uFE0F \u5DF2\u786E\u8BA4\u5E76\u5207\u6362\u4E3A ${workers} workers\u3002\u82E5\u51FA\u73B0\u65AD\u6D41\u3001\u9650\u901F\u3001\u98CE\u63A7\u63D0\u793A\uFF0C\u8BF7\u7ACB\u5373\u964D\u56DE 4 \u6216 8\u3002`, buttons: buildDownloadWorkersKeyboard(workers) }); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u786E\u8BA4 ${workers} workers`, alert: true })); + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u5DF2\u786E\u8BA4 ${workers} workers`, alert: true })); } } catch (error) { console.error("\u{1F916} \u8BBE\u7F6E\u5E76\u53D1\u4E0B\u8F7D worker \u5931\u8D25:", error); - await client2.invoke(new Api3.messages.SetBotCallbackAnswer({ + await client2.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u8BBE\u7F6E\u5931\u8D25: ${error.message}`, alert: true @@ -5473,8 +6396,8 @@ async function handleDownloadWorkersCallback(client2, update, data) { // src/services/ytDlpDownload.ts init_db(); init_storage(); -import fs9 from "fs"; -import path12 from "path"; +import fs10 from "fs"; +import path14 from "path"; import { spawn } from "child_process"; import os2 from "os"; var YtDlpQueue = class { @@ -5504,33 +6427,33 @@ var YTDLP_WORK_DIR = process.env.YTDLP_WORK_DIR || "./data/uploads/ytdlp"; var YTDLP_MAX_CONCURRENT = Math.max(1, parseInt(process.env.YTDLP_MAX_CONCURRENT || "1", 10) || 1); var ytDlpQueue = new YtDlpQueue(YTDLP_MAX_CONCURRENT); function ensureDir(p) { - if (!fs9.existsSync(p)) { - fs9.mkdirSync(p, { recursive: true }); + if (!fs10.existsSync(p)) { + fs10.mkdirSync(p, { recursive: true }); } } function safeRmDir(dir) { try { - if (fs9.existsSync(dir)) { - fs9.rmSync(dir, { recursive: true, force: true }); + if (fs10.existsSync(dir)) { + fs10.rmSync(dir, { recursive: true, force: true }); } } catch { } } function selectPrimaryOutputFile(taskDir) { - const entries = fs9.readdirSync(taskDir, { withFileTypes: true }); + const entries = fs10.readdirSync(taskDir, { withFileTypes: true }); const files = entries.filter((e) => e.isFile()).map((e) => ({ name: e.name, - fullPath: path12.join(taskDir, e.name) + fullPath: path14.join(taskDir, e.name) })).filter((f) => !f.name.endsWith(".part") && !f.name.endsWith(".ytdl") && !f.name.endsWith(".json") && !f.name.endsWith(".tmp")).map((f) => ({ ...f, - size: fs9.existsSync(f.fullPath) ? fs9.statSync(f.fullPath).size : 0 + size: fs10.existsSync(f.fullPath) ? fs10.statSync(f.fullPath).size : 0 })).filter((f) => f.size > 0).sort((a, b) => b.size - a.size); if (files.length === 0) return null; return { filePath: files[0].fullPath, fileName: files[0].name, size: files[0].size }; } async function runYtDlpDownload(url, taskDir) { ensureDir(taskDir); - const outputTemplate = path12.join(taskDir, "%(title).200s-%(id)s.%(ext)s"); + const outputTemplate = path14.join(taskDir, "%(title).200s-%(id)s.%(ext)s"); const args = [ "--no-playlist", "--newline", @@ -5571,13 +6494,13 @@ async function uploadDownloadedFile(localFilePath, originalFileName) { const provider = storageManager.getProvider(); const activeAccountId = storageManager.getActiveAccountId(); const safeName = sanitizeFilename(originalFileName); - const ext = path12.extname(safeName) || path12.extname(localFilePath) || ""; + const ext = path14.extname(safeName) || path14.extname(localFilePath) || ""; const mimeType = getMimeTypeFromFilename(safeName); const fileType = getFileType(mimeType); const storageRules = await getStoragePathRules(); const folder = buildStorageFolderWithRules({ source: "ytdlp", mimeType, fileName: safeName }, storageRules) || "ytdlp"; const storedName = await getUniqueStoredName(safeName, folder, activeAccountId); - const stats = await fs9.promises.stat(localFilePath); + const stats = await fs10.promises.stat(localFilePath); const size = stats.size; const duplicateMode = await getDuplicateMode(); if (duplicateMode === "skip") { @@ -5588,14 +6511,16 @@ async function uploadDownloadedFile(localFilePath, originalFileName) { } let thumbnailPath = null; let dimensions = {}; - try { - thumbnailPath = await generateThumbnail(localFilePath, storedName, mimeType); - dimensions = await getImageDimensions(localFilePath, mimeType); - } catch { + 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); try { - if (fs9.existsSync(localFilePath)) await fs9.promises.unlink(localFilePath); + if (fs10.existsSync(localFilePath)) await fs10.promises.unlink(localFilePath); } catch { } await query(` @@ -5611,9 +6536,9 @@ async function handleYtDlpCommand(message, url) { status: "pending", createdAt: Date.now() }; - const workBaseDir = path12.isAbsolute(YTDLP_WORK_DIR) ? YTDLP_WORK_DIR : path12.join(process.cwd(), YTDLP_WORK_DIR); + const workBaseDir = path14.isAbsolute(YTDLP_WORK_DIR) ? YTDLP_WORK_DIR : path14.join(process.cwd(), YTDLP_WORK_DIR); ensureDir(workBaseDir); - const taskDir = path12.join(workBaseDir, task.id); + const taskDir = path14.join(workBaseDir, task.id); await message.reply({ message: `\u23EC \u5F00\u59CB\u89E3\u6790\u5E76\u4E0B\u8F7D... Task: ${task.id}` }); ytDlpQueue.add(async () => { @@ -5655,319 +6580,6 @@ Task: ${task.id}` }); }); } -// src/services/telegramChannelJobs.ts -init_db(); -var SUBSCRIPTION_INTERVAL_MS = Math.max(6e4, parseInt(process.env.TELEGRAM_SUBSCRIPTION_INTERVAL_MS || "300000", 10) || 3e5); -var SUBSCRIPTION_SCAN_LIMIT = Math.max(1, parseInt(process.env.TELEGRAM_SUBSCRIPTION_SCAN_LIMIT || "100", 10) || 100); -var subscriptionTimer = null; -function requireUserClient() { - const userClient2 = getTelegramUserClient(); - if (!userClient2 || !isTelegramUserClientReady()) { - throw new Error("Telegram \u7528\u6237\u8D26\u53F7\u4E0B\u8F7D\u5668\u672A\u5C31\u7EEA"); - } - return userClient2; -} -function normalizeSource(source) { - const trimmed = source.trim(); - if (!trimmed) throw new Error("\u9891\u9053\u4E0D\u80FD\u4E3A\u7A7A"); - if (trimmed.startsWith("@") || /^-?\d+$/.test(trimmed) || /^https?:\/\//i.test(trimmed)) return trimmed; - return `@${trimmed}`; -} -function getEntityTitle(entity, fallback) { - return entity?.title || [entity?.firstName, entity?.lastName].filter(Boolean).join(" ") || entity?.username || fallback; -} -function messageHasMedia(message) { - if (!message) return false; - return Boolean(message.media || message.document || message.photo || message.video || message.audio || message.voice || message.sticker); -} -function normalizeHashtag(tagInput) { - const trimmed = tagInput.trim(); - if (!trimmed) throw new Error("\u6807\u7B7E\u4E0D\u80FD\u4E3A\u7A7A"); - const withoutHash = trimmed.replace(/^#+/, ""); - if (!withoutHash || /\s/.test(withoutHash)) throw new Error("\u6807\u7B7E\u683C\u5F0F\u5E94\u4E3A #xxx\uFF0C\u4E0D\u80FD\u5305\u542B\u7A7A\u683C"); - return `#${withoutHash}`; -} -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} -function messageTextForTag(message) { - if (!message) return ""; - return [message.message, message.text, message.caption].filter(Boolean).join("\n"); -} -function messageMatchesHashtag(message, normalizedTag) { - const body = messageTextForTag(message); - if (!body) return false; - const tag = escapeRegExp(normalizedTag.slice(1)); - const pattern = new RegExp(`(^|[^\\p{L}\\p{N}_])#${tag}(?![\\p{L}\\p{N}_])`, "iu"); - return pattern.test(body); -} -async function getLatestMessageId(userClient2, source) { - const [latest] = await userClient2.getMessages(source, { limit: 1 }); - return latest?.id || 0; -} -async function getMessagesByDateRange(userClient2, source, startDate, endDate, maxScan = 5e3) { - const result = []; - let offsetId = 0; - while (result.length < maxScan) { - const batch = await userClient2.getMessages(source, { limit: Math.min(100, maxScan - result.length), offsetId }); - if (!batch.length) break; - let reachedOlder = false; - for (const message of batch) { - offsetId = message.id; - const messageDate = new Date((message.date || 0) * 1e3); - if (messageDate > endDate) continue; - if (messageDate < startDate) { - reachedOlder = true; - break; - } - if (messageHasMedia(message)) result.push(message); - } - if (reachedOlder) break; - } - return result.sort((a, b) => a.id - b.id); -} -function messageGroupId(message) { - const groupedId = message?.groupedId; - return groupedId ? groupedId.toString() : void 0; -} -async function expandMessagesWithMediaGroups(userClient2, source, messages) { - const byId = /* @__PURE__ */ new Map(); - const seenGroups = /* @__PURE__ */ new Set(); - for (const message of messages) { - if (messageHasMedia(message)) byId.set(message.id, message); - const groupId = messageGroupId(message); - if (!groupId || seenGroups.has(groupId)) continue; - seenGroups.add(groupId); - const ids = Array.from({ length: 41 }, (_, index) => message.id - 20 + index).filter((id) => id > 0); - const nearby = await userClient2.getMessages(source, { ids }); - for (const candidate of nearby) { - if (candidate && messageHasMedia(candidate) && messageGroupId(candidate) === groupId) { - byId.set(candidate.id, candidate); - } - } - } - return Array.from(byId.values()).sort((a, b) => a.id - b.id); -} -async function persistDownloadItems(jobId, source, messages) { - for (const message of messages) { - await query( - `INSERT INTO telegram_download_items (job_id, source, message_id, grouped_id, status) - VALUES ($1, $2, $3, $4, 'pending') - ON CONFLICT (job_id, message_id) DO NOTHING`, - [jobId, source, message.id, messageGroupId(message) || null] - ); - } -} -async function updateDownloadItemsStatus(jobId, messageIds, status, error) { - const ids = Array.from(new Set((messageIds || []).filter((id) => id > 0))); - if (ids.length === 0) return; - await query( - `UPDATE telegram_download_items - SET status = $2, error = $3, updated_at = NOW() - WHERE job_id = $1 AND message_id = ANY($4::int[])`, - [jobId, status, error || null, ids] - ); -} -function parseDateOnly(value, endOfDay = false) { - const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); - if (!match) throw new Error("\u65E5\u671F\u683C\u5F0F\u5FC5\u987B\u662F YYYY-MM-DD"); - const [, year, month, day] = match; - return /* @__PURE__ */ new Date(`${year}-${month}-${day}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z`); -} -async function createJob(userId, chatId, kind, source, params) { - const result = await query( - `INSERT INTO telegram_background_jobs (user_id, chat_id, kind, source, params) - VALUES ($1, $2, $3, $4, $5) - RETURNING id`, - [userId, chatId || null, kind, source, JSON.stringify(params)] - ); - return result.rows[0].id; -} -async function updateJob(jobId, updates) { - const entries = Object.entries(updates); - if (entries.length === 0) return; - const setSql = entries.map(([key], index) => `${key} = $${index + 2}`).join(", "); - await query(`UPDATE telegram_background_jobs SET ${setSql}, updated_at = NOW() WHERE id = $1`, [jobId, ...entries.map(([, value]) => value)]); -} -async function subscribeTelegramChannel(userId, chatId, sourceInput) { - const userClient2 = requireUserClient(); - const source = normalizeSource(sourceInput); - const entity = await userClient2.getEntity(source); - const latestMessageId = await getLatestMessageId(userClient2, 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) - 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] - ); - return result.rows[0]; -} -async function listTelegramSubscriptions(userId, includeDisabled = false) { - const result = await query( - `SELECT id, source, title, last_message_id, enabled, updated_at - FROM telegram_channel_subscriptions - WHERE user_id = $1 - AND ($2::boolean OR enabled = true) - ORDER BY updated_at DESC`, - [userId, includeDisabled] - ); - return result.rows; -} -async function unsubscribeTelegramChannel(userId, selector) { - const trimmed = selector.trim(); - const normalizedSelector = /^@|^https?:\/\//i.test(trimmed) || /^-?\d+$/.test(trimmed) ? normalizeSource(trimmed) : trimmed; - const result = await query( - `UPDATE telegram_channel_subscriptions - SET enabled = false, updated_at = NOW() - WHERE user_id = $1 AND (source = $2 OR id::text LIKE $3) - RETURNING source, title`, - [userId, normalizedSelector, `${trimmed}%`] - ); - return result.rows[0] || null; -} -async function enqueueTelegramDateDownload(botClient, requestMessage, userId, sourceInput, startDateText, endDateText) { - const userClient2 = requireUserClient(); - const source = normalizeSource(sourceInput); - const startDate = parseDateOnly(startDateText); - const endDate = parseDateOnly(endDateText, true); - if (startDate > endDate) throw new Error("\u5F00\u59CB\u65E5\u671F\u4E0D\u80FD\u665A\u4E8E\u7ED3\u675F\u65E5\u671F"); - const baseMessages = await getMessagesByDateRange(userClient2, source, startDate, endDate); - const messages = await expandMessagesWithMediaGroups(userClient2, 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 }); - await persistDownloadItems(jobId, source, messages); - await updateJob(jobId, { status: "running", started_at: /* @__PURE__ */ new Date(), total_count: messages.length }); - try { - const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, messages.length, "older", messages.map((message) => message.id)); - await updateDownloadItemsStatus(jobId, result.successfulMessageIds, "success"); - await updateDownloadItemsStatus(jobId, result.failedMessageIds, "failed", "\u4E0B\u8F7D\u5931\u8D25"); - await updateDownloadItemsStatus(jobId, result.skippedMessageIds, "skipped"); - await updateJob(jobId, { - status: result.failed > 0 ? "completed_with_errors" : "completed", - enqueued_count: result.found, - skipped_count: result.skipped, - error: result.failed > 0 ? `${result.failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null, - finished_at: /* @__PURE__ */ new Date() - }); - return { jobId, ...result }; - } catch (error) { - await updateJob(jobId, { status: "failed", error: error instanceof Error ? error.message : String(error), finished_at: /* @__PURE__ */ new Date() }); - throw error; - } -} -async function getMessagesByHashtag(userClient2, source, tag, maxScan = 1e4) { - const normalizedTag = normalizeHashtag(tag); - const result = []; - let offsetId = 0; - while (result.length < maxScan) { - const batch = await userClient2.getMessages(source, { - limit: Math.min(100, maxScan - result.length), - offsetId, - search: normalizedTag - }); - if (!batch.length) break; - for (const message of batch) { - offsetId = message.id; - if (messageHasMedia(message) && messageMatchesHashtag(message, normalizedTag)) { - result.push(message); - } - } - } - return result.sort((a, b) => a.id - b.id); -} -async function enqueueTelegramTagDownload(botClient, requestMessage, userId, sourceInput, tagInput) { - const userClient2 = requireUserClient(); - const source = normalizeSource(sourceInput); - const tag = normalizeHashtag(tagInput); - const baseMessages = await getMessagesByHashtag(userClient2, source, tag); - const messages = await expandMessagesWithMediaGroups(userClient2, source, baseMessages); - const messageIds = messages.map((message) => message.id); - const jobId = await createJob(userId, requestMessage.chatId?.toString(), "tag_download", source, { tag, messageIds }); - await persistDownloadItems(jobId, source, messages); - await updateJob(jobId, { status: "running", started_at: /* @__PURE__ */ new Date(), total_count: messages.length }); - try { - const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, messages.length, "older", messages.map((message) => message.id)); - await updateDownloadItemsStatus(jobId, result.successfulMessageIds, "success"); - await updateDownloadItemsStatus(jobId, result.failedMessageIds, "failed", "\u4E0B\u8F7D\u5931\u8D25"); - await updateDownloadItemsStatus(jobId, result.skippedMessageIds, "skipped"); - await updateJob(jobId, { - status: result.failed > 0 ? "completed_with_errors" : "completed", - enqueued_count: result.found, - skipped_count: result.skipped, - error: result.failed > 0 ? `${result.failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null, - finished_at: /* @__PURE__ */ new Date() - }); - return { jobId, tag, ...result }; - } catch (error) { - await updateJob(jobId, { status: "failed", error: error instanceof Error ? error.message : String(error), finished_at: /* @__PURE__ */ new Date() }); - throw error; - } -} -async function listTelegramBackgroundJobs(userId, limit = 10) { - const result = await query( - `SELECT id, kind, source, status, total_count, enqueued_count, skipped_count, duplicate_count, error, created_at, updated_at - FROM telegram_background_jobs - WHERE user_id = $1 - ORDER BY created_at DESC - LIMIT $2`, - [userId, limit] - ); - return result.rows; -} -async function runSubscriptionScan(botClient) { - const userClient2 = getTelegramUserClient(); - if (!userClient2 || !isTelegramUserClientReady()) return; - const result = await query( - `SELECT id, user_id, chat_id, source, last_message_id - FROM telegram_channel_subscriptions - WHERE enabled = true - ORDER BY updated_at ASC` - ); - for (const row of result.rows) { - try { - const latestMessageId = await getLatestMessageId(userClient2, row.source); - const lastMessageId = Number(row.last_message_id || 0); - if (!latestMessageId || latestMessageId <= lastMessageId) continue; - const count = Math.min(SUBSCRIPTION_SCAN_LIMIT, latestMessageId - lastMessageId); - const ids = Array.from({ length: count }, (_, index) => lastMessageId + index + 1); - const jobId = await createJob(Number(row.user_id), row.chat_id?.toString(), "subscription_sync", row.source, { fromId: lastMessageId + 1, toId: latestMessageId }); - const candidateMessages = await expandMessagesWithMediaGroups(userClient2, row.source, (await userClient2.getMessages(row.source, { ids })).filter(Boolean)); - await persistDownloadItems(jobId, row.source, candidateMessages); - await updateJob(jobId, { status: "running", started_at: /* @__PURE__ */ new Date(), total_count: ids.length }); - const targetChat = row.chat_id || row.user_id; - const requestMessage = { chatId: targetChat, id: latestMessageId }; - const downloadResult = await downloadTelegramChannelRange(botClient, requestMessage, row.source, 0, ids.length, "newer", ids); - await updateDownloadItemsStatus(jobId, downloadResult.successfulMessageIds, "success"); - await updateDownloadItemsStatus(jobId, downloadResult.failedMessageIds, "failed", "\u4E0B\u8F7D\u5931\u8D25"); - await updateDownloadItemsStatus(jobId, downloadResult.skippedMessageIds, "skipped"); - await updateJob(jobId, { - status: downloadResult.failed > 0 ? "completed_with_errors" : "completed", - enqueued_count: downloadResult.found, - skipped_count: downloadResult.skipped, - error: downloadResult.failed > 0 ? `${downloadResult.failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null, - finished_at: /* @__PURE__ */ new Date() - }); - await query("UPDATE telegram_channel_subscriptions SET last_message_id = $1, updated_at = NOW() WHERE id = $2", [latestMessageId, row.id]); - if (downloadResult.found > 0) { - await botClient.sendMessage(targetChat, { message: `\u2705 \u8BA2\u9605 ${row.source} \u5DF2\u540C\u6B65 ${downloadResult.found} \u4E2A\u65B0\u6587\u4EF6\uFF0C\u8DF3\u8FC7 ${downloadResult.skipped} \u6761\u3002` }).catch(() => void 0); - } - } catch (error) { - console.error("\u{1F916} Telegram \u8BA2\u9605\u540C\u6B65\u5931\u8D25:", error); - } - } -} -function startTelegramSubscriptionWorker(botClient) { - if (subscriptionTimer) return; - subscriptionTimer = setInterval(() => { - runSubscriptionScan(botClient).catch((error) => console.error("\u{1F916} Telegram \u8BA2\u9605\u626B\u63CF\u5F02\u5E38:", error)); - }, SUBSCRIPTION_INTERVAL_MS); - runSubscriptionScan(botClient).catch((error) => console.error("\u{1F916} Telegram \u8BA2\u9605\u626B\u63CF\u5F02\u5E38:", error)); - console.log(`\u{1F916} Telegram \u9891\u9053\u8BA2\u9605\u626B\u63CF\u5DF2\u542F\u52A8\uFF0C\u95F4\u9694 ${Math.round(SUBSCRIPTION_INTERVAL_MS / 1e3)} \u79D2`); -} - // src/services/telegramBot.ts init_db(); @@ -6022,17 +6634,68 @@ async function assertPublicHttpUrl(rawUrl) { } return parsed; } +async function assertPublicStorageEndpoint(rawUrl) { + const parsed = await assertPublicHttpUrl(rawUrl); + if (parsed.protocol !== "https:" && process.env.ALLOW_INSECURE_STORAGE_ENDPOINTS !== "true") { + throw new Error("\u5B58\u50A8\u7AEF\u70B9\u4EC5\u5141\u8BB8 https\uFF1B\u5982\u786E\u9700 http\uFF0C\u8BF7\u663E\u5F0F\u8BBE\u7F6E ALLOW_INSECURE_STORAGE_ENDPOINTS=true"); + } + return parsed; +} // src/services/telegramBot.ts var SESSION_FILE = process.env.TELEGRAM_SESSION_FILE || "./data/telegram_session.txt"; var client = null; +function buildTelegramDownloadModeKeyboard() { + return new Api5.ReplyInlineMarkup({ + rows: [ + new Api5.KeyboardButtonRow({ + buttons: [ + new Api5.KeyboardButtonCallback({ text: "\u{1F5D3}\uFE0F \u6309\u65E5\u671F\u4E0B\u8F7D", data: Buffer.from("tgd_mode_date") }), + new Api5.KeyboardButtonCallback({ text: "\u{1F3F7}\uFE0F \u6309\u6807\u7B7E\u4E0B\u8F7D", data: Buffer.from("tgd_mode_tag") }) + ] + }), + new Api5.KeyboardButtonRow({ + buttons: [new Api5.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("tgd_cancel") })] + }) + ] + }); +} var telegramWizardStates = /* @__PURE__ */ new Map(); var telegramRateBuckets = /* @__PURE__ */ new Map(); var TELEGRAM_MESSAGE_RATE_WINDOW_MS = Math.max(1e4, parseInt(process.env.TELEGRAM_RATE_WINDOW_MS || "60000", 10) || 6e4); var TELEGRAM_MESSAGE_RATE_MAX = Math.max(5, parseInt(process.env.TELEGRAM_RATE_MAX || "30", 10) || 30); var TELEGRAM_HEAVY_RATE_WINDOW_MS = Math.max(6e4, parseInt(process.env.TELEGRAM_HEAVY_RATE_WINDOW_MS || "600000", 10) || 6e5); var TELEGRAM_HEAVY_RATE_MAX = Math.max(1, parseInt(process.env.TELEGRAM_HEAVY_RATE_MAX || "5", 10) || 5); -var TELEGRAM_HEAVY_COMMANDS = /* @__PURE__ */ new Set(["/ytdlp", "/tg_date", "/tg_tag", "/cleanup_settings"]); +var TELEGRAM_HEAVY_COMMANDS = /* @__PURE__ */ new Set(["/ytdlp", "/tg_download", "/tg_date", "/tg_tag", "/cleanup_settings"]); +var pinFailureState = /* @__PURE__ */ new Map(); +var TELEGRAM_PIN_FAIL_WINDOW_MS = Math.max(6e4, parseInt(process.env.TELEGRAM_PIN_FAIL_WINDOW_MS || "900000", 10) || 9e5); +var TELEGRAM_PIN_FAIL_MAX = Math.max(3, parseInt(process.env.TELEGRAM_PIN_FAIL_MAX || "5", 10) || 5); +var TELEGRAM_PIN_LOCK_MS = Math.max(6e4, parseInt(process.env.TELEGRAM_PIN_LOCK_MS || "900000", 10) || 9e5); +var TELEGRAM_PIN_REQUIRED_LENGTH = 4; +function getPinLockSeconds(userId) { + const state = pinFailureState.get(userId); + if (!state?.lockedUntil) return 0; + const remaining = state.lockedUntil - Date.now(); + if (remaining <= 0) { + pinFailureState.delete(userId); + return 0; + } + return Math.ceil(remaining / 1e3); +} +function recordPinFailure(userId) { + const now = Date.now(); + const current = pinFailureState.get(userId); + const state = !current || now - current.windowStartedAt >= TELEGRAM_PIN_FAIL_WINDOW_MS ? { windowStartedAt: now, failed: 0 } : current; + state.failed += 1; + if (state.failed >= TELEGRAM_PIN_FAIL_MAX) { + state.lockedUntil = now + TELEGRAM_PIN_LOCK_MS; + } + pinFailureState.set(userId, state); + return { locked: Boolean(state.lockedUntil && state.lockedUntil > now), retryAfterSeconds: state.lockedUntil ? Math.ceil((state.lockedUntil - now) / 1e3) : 0 }; +} +function clearPinFailures(userId) { + pinFailureState.delete(userId); +} function consumeTelegramRateLimit(userId, text) { const now = Date.now(); const normalized = text.trim().split(/\s+/, 1)[0].replace(/@\w+$/, "").toLowerCase(); @@ -6066,7 +6729,19 @@ function isCancelInput(text) { return /^(取消|cancel|退出|stop)$/i.test(text.trim()); } function buildTelegramWizardPrompt(state) { - const title = state.kind === "tg_sub_manage" ? "\u{1F4E1} **\u8BA2\u9605\u9891\u9053\u7BA1\u7406**" : state.kind === "tg_tag" ? "\u{1F3F7}\uFE0F **\u6309\u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6**" : "\u{1F5D3}\uFE0F **\u6309\u65E5\u671F\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6**"; + const title = state.kind === "tg_sub_manage" ? "\u{1F4E1} **\u8BA2\u9605\u9891\u9053\u7BA1\u7406**" : state.kind === "tg_tag" ? "\u{1F3F7}\uFE0F **\u6309\u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6**" : state.kind === "tg_date" ? "\u{1F5D3}\uFE0F **\u6309\u65E5\u671F\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6**" : "\u{1F4E6} **\u9891\u9053\u6587\u4EF6\u4E0B\u8F7D**"; + if (state.step === "mode") { + return [ + title, + "", + "\u8BF7\u9009\u62E9\u4E0B\u8F7D\u65B9\u5F0F\uFF1A", + "`\u65E5\u671F` \u2014 \u4E0B\u8F7D\u67D0\u4E2A\u65E5\u671F\u8303\u56F4\u5185\u7684\u9891\u9053\u5A92\u4F53", + "`\u6807\u7B7E` \u2014 \u4E0B\u8F7D\u5E26\u6307\u5B9A #\u6807\u7B7E \u7684\u9891\u9053\u5A92\u4F53", + "", + "\u4E5F\u53EF\u4EE5\u76F4\u63A5\u53D1\u9001\uFF1A`date` / `tag`\u3002", + "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" + ].join("\n"); + } if (state.step === "source") { return [ title, @@ -6077,10 +6752,25 @@ function buildTelegramWizardPrompt(state) { "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" ].join("\n"); } + if (state.step === "path") { + const scopeText = state.kind === "tg_sub_manage" ? state.subscriptionId ? "\u8FD9\u4E2A\u8BA2\u9605" : "\u672C\u6B21\u8BA2\u9605" : "\u672C\u6B21\u4E0B\u8F7D\u4EFB\u52A1"; + return [ + title, + `\u{1F4CD} \u9891\u9053\uFF1A${state.subscriptionSource || state.source}`, + "", + `\u662F\u5426\u8981\u7ED9${scopeText}\u5355\u72EC\u6307\u5B9A\u4FDD\u5B58\u76EE\u5F55\uFF1F`, + "", + "\u76F4\u63A5\u53D1\u9001\u76EE\u5F55\uFF0C\u4F8B\u5982\uFF1A`\u9891\u9053\u5907\u4EFD/\u58C1\u7EB8`", + "\u53D1\u9001 `\u8DF3\u8FC7` / `skip` \u4F7F\u7528\u9ED8\u8BA4\u4FDD\u5B58\u8DEF\u5F84\u89C4\u5219\u3002", + "", + `\u8BF4\u660E\uFF1A\u8FD9\u91CC\u8BBE\u7F6E\u7684\u76EE\u5F55\u53EA\u5BF9${scopeText}\u751F\u6548\uFF0C\u4E0D\u4F1A\u6539\u53D8\u5168\u5C40 /path_rules\uFF0C\u4E5F\u4E0D\u4F1A\u5F71\u54CD\u5176\u5B83\u4E0B\u8F7D\u3002`, + "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" + ].join("\n"); + } if (state.step === "tag") { return [ title, - `\u{1F4CD} \u9891\u9053\uFF1A${state.source}`, + `\u{1F4CD} \u9891\u9053\uFF1A${state.subscriptionSource || state.source}`, "", "\u8BF7\u53D1\u9001\u8981\u4E0B\u8F7D\u7684\u6807\u7B7E\uFF1A", "\u4F8B\u5982\uFF1A`#\u58C1\u7EB8` \u6216 `\u58C1\u7EB8`", @@ -6091,7 +6781,7 @@ function buildTelegramWizardPrompt(state) { if (state.step === "start_date") { return [ title, - `\u{1F4CD} \u9891\u9053\uFF1A${state.source}`, + `\u{1F4CD} \u9891\u9053\uFF1A${state.subscriptionSource || state.source}`, "", "\u8BF7\u53D1\u9001\u5F00\u59CB\u65E5\u671F\uFF1A", "\u683C\u5F0F\uFF1A`YYYY-MM-DD`\uFF0C\u4F8B\u5982 `2026-06-01`", @@ -6113,15 +6803,36 @@ function buildTelegramWizardPrompt(state) { function isDateOnly(text) { return /^\d{4}-\d{2}-\d{2}$/.test(text.trim()); } +async function replyWithJobResult(statusMessage, fallbackMessage, promise, kind) { + promise.then((result) => { + const text = kind === "tag" ? `\u2705 \u6807\u7B7E\u4E0B\u8F7D\u4EFB\u52A1\u5B8C\u6210 +\u6807\u7B7E: ${result.tag} +ID: ${String(result.jobId).slice(0, 8)} +\u5165\u961F: ${result.found} +\u8DF3\u8FC7: ${result.skipped} +\u5931\u8D25: ${result.failed}` : `\u2705 \u65E5\u671F\u8303\u56F4\u4EFB\u52A1\u5B8C\u6210 +ID: ${String(result.jobId).slice(0, 8)} +\u5165\u961F: ${result.found} +\u8DF3\u8FC7: ${result.skipped} +\u5931\u8D25: ${result.failed}`; + statusMessage.edit({ text }).catch(() => fallbackMessage.reply({ message: text }).catch(() => void 0)); + }).catch((error) => { + const text = `\u274C ${kind === "tag" ? "\u6807\u7B7E" : "\u65E5\u671F"}\u4E0B\u8F7D\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}`; + statusMessage.edit({ text }).catch(() => fallbackMessage.reply({ message: text }).catch(() => void 0)); + }); +} async function startTelegramWizard(message, senderId, kind) { - const state = { kind, step: "source" }; + const state = { kind, step: kind === "tg_download" ? "mode" : "source" }; telegramWizardStates.set(senderId, state); if (kind === "tg_sub_manage") { const rows = await listTelegramSubscriptions(senderId); - await message.reply({ message: buildSubscriptionManagePanel(rows) }); + await message.reply({ message: buildSubscriptionManagePanel(rows), buttons: buildSubscriptionActionKeyboard(rows) }); return; } - await message.reply({ message: buildTelegramWizardPrompt(state) }); + await message.reply({ + message: buildTelegramWizardPrompt(state), + buttons: kind === "tg_download" ? buildTelegramDownloadModeKeyboard() : void 0 + }); } async function handleTelegramWizardMessage(message, senderId, text) { const state = telegramWizardStates.get(senderId); @@ -6133,6 +6844,21 @@ async function handleTelegramWizardMessage(message, senderId, text) { await message.reply({ message: "\u5DF2\u53D6\u6D88 Telegram \u9891\u9053\u64CD\u4F5C\u5411\u5BFC\u3002" }); return true; } + if (state.step === "mode") { + const normalizedMode = input.toLowerCase(); + if (["date", "\u65E5\u671F", "\u6309\u65E5\u671F"].includes(normalizedMode)) { + state.kind = "tg_date"; + state.step = "source"; + } else if (["tag", "\u6807\u7B7E", "\u6309\u6807\u7B7E"].includes(normalizedMode)) { + state.kind = "tg_tag"; + state.step = "source"; + } else { + await message.reply({ message: "\u274C \u8BF7\u53D1\u9001 `date`/`\u65E5\u671F` \u6216 `tag`/`\u6807\u7B7E`\uFF0C\u4E5F\u53EF\u4EE5\u53D1\u9001\u201C\u53D6\u6D88\u201D\u9000\u51FA\u3002" }); + return true; + } + await message.reply({ message: buildTelegramWizardPrompt(state) }); + return true; + } if (state.step === "source") { state.source = input; if (state.kind === "tg_sub_manage") { @@ -6160,14 +6886,58 @@ async function handleTelegramWizardMessage(message, senderId, text) { await message.reply({ message: "\u274C \u8BF7\u56DE\u590D\u8BA2\u9605\u5E8F\u53F7\u6765\u53D6\u6D88\uFF0C\u6216\u53D1\u9001\u9891\u9053\u7528\u6237\u540D/\u94FE\u63A5\u6765\u65B0\u589E\u8BA2\u9605\uFF0C\u4F8B\u5982\uFF1A`@channel_username`\u3002" }); return true; } + state.step = "path"; + await message.reply({ message: buildTelegramWizardPrompt(state) }); + return true; + } + state.step = "path"; + await message.reply({ message: buildTelegramWizardPrompt(state) }); + return true; + } + if (state.step === "path") { + const skipPath = /^(跳过|skip|默认|default|无|不用|不指定)$/i.test(input); + if (skipPath) { + delete state.customFolder; + } else { + try { + state.customFolder = await rememberRecentTelegramPathPersistent(message.chatId?.toString() || "unknown", input); + } catch (error) { + await message.reply({ message: `\u274C \u8DEF\u5F84\u65E0\u6548\uFF1A${error.message} + +\u8BF7\u91CD\u65B0\u53D1\u9001\u76EE\u5F55\uFF0C\u6216\u53D1\u9001\u201C\u8DF3\u8FC7\u201D\u4F7F\u7528\u9ED8\u8BA4\u4FDD\u5B58\u8DEF\u5F84\u89C4\u5219\u3002` }); + return true; + } + } + if (state.kind === "tg_sub_manage") { telegramWizardStates.delete(senderId); try { - const sub = await subscribeTelegramChannel(senderId, message.chatId?.toString(), input); - await message.reply({ message: `\u2705 \u5DF2\u8BA2\u9605 ${sub.title || sub.source} -\u{1F4CD} ${sub.source} -\u4ECE\u5F53\u524D\u6700\u65B0\u6D88\u606F ID ${sub.last_message_id || 0} \u4E4B\u540E\u5F00\u59CB\u81EA\u52A8\u540C\u6B65\u3002` }); + if (state.subscriptionId) { + const sub = await updateTelegramSubscriptionFolder(senderId, state.subscriptionId, state.customFolder || null); + const rowsAfterUpdate = await listTelegramSubscriptions(senderId); + await message.reply({ + message: [ + sub ? `\u2705 \u5DF2\u66F4\u65B0\u8BA2\u9605\u76EE\u5F55\uFF1A${sub.title || sub.source}` : "\u274C \u672A\u627E\u5230\u8BE5\u8BA2\u9605", + sub && state.customFolder ? `\u{1F4C1} \u4E13\u5C5E\u76EE\u5F55\uFF1A${state.customFolder} +${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u4FDD\u5B58\u7B56\u7565\uFF1A\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B", + "", + buildSubscriptionManagePanel(rowsAfterUpdate) + ].filter(Boolean).join("\n"), + buttons: buildSubscriptionActionKeyboard(rowsAfterUpdate) + }); + } else { + const sub = await subscribeTelegramChannel(senderId, message.chatId?.toString(), state.source, state.customFolder); + await message.reply({ + message: [ + `\u2705 \u5DF2\u8BA2\u9605 ${sub.title || sub.source}`, + `\u{1F4CD} ${sub.source}`, + state.customFolder ? `\u{1F4C1} \u672C\u8BA2\u9605\u4E13\u5C5E\u4FDD\u5B58\u76EE\u5F55\uFF1A${state.customFolder} +${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u672C\u8BA2\u9605\u4F7F\u7528\u9ED8\u8BA4\u4FDD\u5B58\u8DEF\u5F84\u89C4\u5219", + `\u4ECE\u5F53\u524D\u6700\u65B0\u6D88\u606F ID ${sub.last_message_id || 0} \u4E4B\u540E\u5F00\u59CB\u81EA\u52A8\u540C\u6B65\u3002` + ].join("\n") + }); + } } catch (error) { - await message.reply({ message: `\u274C \u8BA2\u9605\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` }); + await message.reply({ message: `\u274C \u8BA2\u9605\u64CD\u4F5C\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` }); } return true; } @@ -6182,13 +6952,9 @@ async function handleTelegramWizardMessage(message, senderId, text) { if (state.step === "tag") { telegramWizardStates.delete(senderId); try { - await message.reply({ message: `\u23F3 \u6B63\u5728\u626B\u63CF ${state.source} \u4E2D\u5E26\u6709 ${input.startsWith("#") ? input : `#${input}`} \u7684\u5A92\u4F53\u6D88\u606F...` }); - const result = await enqueueTelegramTagDownload(client, message, senderId, state.source, input); - await message.reply({ message: `\u2705 \u6807\u7B7E\u4E0B\u8F7D\u4EFB\u52A1\u5DF2\u63D0\u4EA4 -\u6807\u7B7E: ${result.tag} -ID: ${String(result.jobId).slice(0, 8)} -\u5165\u961F: ${result.found} -\u8DF3\u8FC7: ${result.skipped}` }); + const queuedMsg = await message.reply({ message: `\u23F3 \u5DF2\u5F00\u59CB\u540E\u53F0\u626B\u63CF ${state.source} \u4E2D\u5E26\u6709 ${input.startsWith("#") ? input : `#${input}`} \u7684\u5A92\u4F53\u6D88\u606F... +\u5B8C\u6210\u540E\u4F1A\u81EA\u52A8\u66F4\u65B0\u7ED3\u679C\uFF0C\u53EF\u7528 /tasks \u67E5\u770B\u540E\u53F0\u4EFB\u52A1\u3002` }); + await replyWithJobResult(queuedMsg, message, enqueueTelegramTagDownload(client, message, senderId, state.source, input, state.customFolder), "tag"); } catch (error) { await message.reply({ message: `\u274C \u6807\u7B7E\u4E0B\u8F7D\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` }); } @@ -6210,28 +6976,47 @@ ID: ${String(result.jobId).slice(0, 8)} } telegramWizardStates.delete(senderId); try { - await message.reply({ message: `\u23F3 \u6B63\u5728\u6309\u65E5\u671F\u626B\u63CF ${state.source}\uFF1A${state.startDate} \u2192 ${input}...` }); - const result = await enqueueTelegramDateDownload(client, message, senderId, state.source, state.startDate, input); - await message.reply({ message: `\u2705 \u65E5\u671F\u8303\u56F4\u4EFB\u52A1\u5DF2\u63D0\u4EA4 -ID: ${String(result.jobId).slice(0, 8)} -\u5165\u961F: ${result.found} -\u8DF3\u8FC7: ${result.skipped}` }); + const queuedMsg = await message.reply({ message: `\u23F3 \u5DF2\u5F00\u59CB\u540E\u53F0\u626B\u63CF ${state.source}\uFF1A${state.startDate} \u2192 ${input}... +\u5B8C\u6210\u540E\u4F1A\u81EA\u52A8\u66F4\u65B0\u7ED3\u679C\uFF0C\u53EF\u7528 /tasks \u67E5\u770B\u540E\u53F0\u4EFB\u52A1\u3002` }); + await replyWithJobResult(queuedMsg, message, enqueueTelegramDateDownload(client, message, senderId, state.source, state.startDate, input, state.customFolder), "date"); } catch (error) { await message.reply({ message: `\u274C \u65E5\u671F\u4E0B\u8F7D\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` }); } return true; } +function buildSubscriptionActionKeyboard(rows) { + if (rows.length === 0) return void 0; + return new Api5.ReplyInlineMarkup({ + rows: rows.slice(0, 8).flatMap((row, index) => [ + new Api5.KeyboardButtonRow({ + buttons: [new Api5.KeyboardButtonCallback({ text: `${index + 1}. ${row.title || row.source}`, data: Buffer.from(`tsub_view_${row.id}`) })] + }), + new Api5.KeyboardButtonRow({ + buttons: [ + new Api5.KeyboardButtonCallback({ text: "\u270F\uFE0F \u4FEE\u6539\u4E13\u5C5E\u76EE\u5F55", data: Buffer.from(`tsub_folder_${row.id}`) }), + new Api5.KeyboardButtonCallback({ text: "\u{1F9F9} \u6E05\u9664\u76EE\u5F55", data: Buffer.from(`tsub_clear_${row.id}`) }), + new Api5.KeyboardButtonCallback({ text: "\u53D6\u6D88\u8BA2\u9605", data: Buffer.from(`tsub_cancel_${row.id}`) }) + ] + }) + ]) + }); +} function buildSubscriptionManagePanel(rows) { return [ "\u{1F4E1} **\u9891\u9053\u8BA2\u9605\u7BA1\u7406**", "", - rows.length > 0 ? rows.map((row, index) => `${index + 1}. ${row.enabled ? "\u2705" : "\u23F8\uFE0F"} ${row.title || row.source} - ${row.source} \xB7 last_id=${row.last_message_id || 0}`).join("\n") : "\u5F53\u524D\u6CA1\u6709\u542F\u7528\u4E2D\u7684\u8BA2\u9605\u3002", + rows.length > 0 ? rows.map((row, index) => [ + `${index + 1}. ${row.enabled ? "\u2705" : "\u23F8\uFE0F"} ${row.title || row.source}`, + ` ${row.source} \xB7 last_id=${row.last_message_id || 0}`, + row.folder_override ? ` \u{1F4C1} \u4E13\u5C5E\u76EE\u5F55\uFF1A${row.folder_override}` : " \u{1F4C1} \u4FDD\u5B58\u7B56\u7565\uFF1A\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B" + ].join("\n")).join("\n") : "\u5F53\u524D\u6CA1\u6709\u542F\u7528\u4E2D\u7684\u8BA2\u9605\u3002", "", - "\u56DE\u590D\u5E8F\u53F7\u53EF\u53D6\u6D88\u8BA2\u9605\u3002", - "\u56DE\u590D\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u53EF\u65B0\u589E\u8BA2\u9605\u3002", + rows.length > 0 ? "\u53EF\u76F4\u63A5\u70B9\u51FB\u8BA2\u9605\u4E0B\u65B9\u6309\u94AE\u4FEE\u6539/\u6E05\u9664\u4E13\u5C5E\u76EE\u5F55\u6216\u53D6\u6D88\u8BA2\u9605\u3002" : "\u56DE\u590D\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u53EF\u65B0\u589E\u8BA2\u9605\u3002", + "\u56DE\u590D\u9891\u9053\u7528\u6237\u540D\u6216\u94FE\u63A5\u4E5F\u53EF\u65B0\u589E\u8BA2\u9605\u3002", "\u4F8B\u5982\uFF1A`@channel_username` \u6216 `https://t.me/channel_username`", "", + "\u65B0\u589E\u8BA2\u9605\u65F6\u4F1A\u8BE2\u95EE\u662F\u5426\u4E3A\u672C\u8BA2\u9605\u5355\u72EC\u6307\u5B9A\u4FDD\u5B58\u76EE\u5F55\uFF1B\u8BE5\u76EE\u5F55\u53EA\u5F71\u54CD\u8FD9\u4E2A\u8BA2\u9605\uFF0C\u4E0D\u4F1A\u6539\u53D8\u5168\u5C40 /path_rules\u3002", + "", "\u53D1\u9001\u201C\u53D6\u6D88\u201D\u53EF\u9000\u51FA\u3002" ].join("\n"); } @@ -6240,59 +7025,50 @@ function formatSubscriptionList(rows) { return [ "\u{1F4E1} **\u9891\u9053\u8BA2\u9605**", "", - ...rows.map((row, index) => `${index + 1}. ${row.enabled ? "\u2705" : "\u23F8\uFE0F"} ${row.title || row.source} - ${row.source} \xB7 last_id=${row.last_message_id || 0} - ID: ${String(row.id).slice(0, 8)}`) - ].join("\n"); -} -function formatJobList(rows) { - if (rows.length === 0) return "\u{1F4ED} \u6682\u65E0 Telegram \u540E\u53F0\u4EFB\u52A1\u8BB0\u5F55\u3002"; - return [ - "\u{1F9FE} **Telegram \u540E\u53F0\u4EFB\u52A1**", - "", ...rows.map((row, index) => [ - `${index + 1}. ${row.status} \xB7 ${row.kind} \xB7 ${row.source}`, - ` \u5165\u961F ${row.enqueued_count || 0}/${row.total_count || 0} \xB7 \u8DF3\u8FC7 ${row.skipped_count || 0} \xB7 \u91CD\u590D ${row.duplicate_count || 0}`, - row.error ? ` \u9519\u8BEF: ${row.error}` : ` ID: ${String(row.id).slice(0, 8)}` + `${index + 1}. ${row.enabled ? "\u2705" : "\u23F8\uFE0F"} ${row.title || row.source}`, + ` ${row.source} \xB7 last_id=${row.last_message_id || 0}`, + row.folder_override ? ` \u{1F4C1} \u4E13\u5C5E\u76EE\u5F55\uFF1A${row.folder_override}` : " \u{1F4C1} \u4FDD\u5B58\u7B56\u7565\uFF1A\u9ED8\u8BA4\u81EA\u52A8\u5206\u7C7B", + ` ID: ${String(row.id).slice(0, 8)}` ].join("\n")) ].join("\n"); } function generatePasswordKeyboard(currentLength) { const display = "\u25CF".repeat(currentLength) + "-".repeat(Math.max(0, 4 - currentLength)); const displayWithSpaces = display.split("").join(" "); - return new Api4.ReplyInlineMarkup({ + return new Api5.ReplyInlineMarkup({ rows: [ - new Api4.KeyboardButtonRow({ + new Api5.KeyboardButtonRow({ buttons: [ - new Api4.KeyboardButtonCallback({ text: `\u{1F512} ${displayWithSpaces}`, data: Buffer.from("pwd_display") }) + new Api5.KeyboardButtonCallback({ text: `\u{1F512} ${displayWithSpaces}`, data: Buffer.from("pwd_display") }) ] }), - new Api4.KeyboardButtonRow({ + new Api5.KeyboardButtonRow({ buttons: [ - new Api4.KeyboardButtonCallback({ text: "1", data: Buffer.from("pwd_1") }), - new Api4.KeyboardButtonCallback({ text: "2", data: Buffer.from("pwd_2") }), - new Api4.KeyboardButtonCallback({ text: "3", data: Buffer.from("pwd_3") }) + new Api5.KeyboardButtonCallback({ text: "1", data: Buffer.from("pwd_1") }), + new Api5.KeyboardButtonCallback({ text: "2", data: Buffer.from("pwd_2") }), + new Api5.KeyboardButtonCallback({ text: "3", data: Buffer.from("pwd_3") }) ] }), - new Api4.KeyboardButtonRow({ + new Api5.KeyboardButtonRow({ buttons: [ - new Api4.KeyboardButtonCallback({ text: "4", data: Buffer.from("pwd_4") }), - new Api4.KeyboardButtonCallback({ text: "5", data: Buffer.from("pwd_5") }), - new Api4.KeyboardButtonCallback({ text: "6", data: Buffer.from("pwd_6") }) + new Api5.KeyboardButtonCallback({ text: "4", data: Buffer.from("pwd_4") }), + new Api5.KeyboardButtonCallback({ text: "5", data: Buffer.from("pwd_5") }), + new Api5.KeyboardButtonCallback({ text: "6", data: Buffer.from("pwd_6") }) ] }), - new Api4.KeyboardButtonRow({ + new Api5.KeyboardButtonRow({ buttons: [ - new Api4.KeyboardButtonCallback({ text: "7", data: Buffer.from("pwd_7") }), - new Api4.KeyboardButtonCallback({ text: "8", data: Buffer.from("pwd_8") }), - new Api4.KeyboardButtonCallback({ text: "9", data: Buffer.from("pwd_9") }) + new Api5.KeyboardButtonCallback({ text: "7", data: Buffer.from("pwd_7") }), + new Api5.KeyboardButtonCallback({ text: "8", data: Buffer.from("pwd_8") }), + new Api5.KeyboardButtonCallback({ text: "9", data: Buffer.from("pwd_9") }) ] }), - new Api4.KeyboardButtonRow({ + new Api5.KeyboardButtonRow({ buttons: [ - new Api4.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("pwd_clear") }), - new Api4.KeyboardButtonCallback({ text: "0", data: Buffer.from("pwd_0") }), - new Api4.KeyboardButtonCallback({ text: "\u232B", data: Buffer.from("pwd_backspace") }) + new Api5.KeyboardButtonCallback({ text: "\u53D6\u6D88", data: Buffer.from("pwd_clear") }), + new Api5.KeyboardButtonCallback({ text: "0", data: Buffer.from("pwd_0") }), + new Api5.KeyboardButtonCallback({ text: "\u232B", data: Buffer.from("pwd_backspace") }) ] }) ] @@ -6303,6 +7079,15 @@ async function handlePasswordCallback(update) { const userId = update.userId.toJSNumber(); const data = Buffer.from(update.data || []).toString("utf-8"); if (!data.startsWith("pwd_")) return; + const lockSeconds = getPinLockSeconds(userId); + if (lockSeconds > 0) { + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: `\u5BC6\u7801\u9519\u8BEF\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7 ${lockSeconds} \u79D2\u540E\u518D\u8BD5`, + alert: true + })); + return; + } let state = passwordInputState.get(userId); if (!state) { state = { password: "" }; @@ -6310,7 +7095,7 @@ async function handlePasswordCallback(update) { } try { if (data === "pwd_display") { - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId })); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId })); return; } if (data === "pwd_backspace") { @@ -6322,44 +7107,61 @@ async function handlePasswordCallback(update) { message: update.msgId, text: MSG.AUTH_CANCELLED }); - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId })); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId })); return; } else { const digit = data.replace("pwd_", ""); if (/^[0-9]$/.test(digit)) { - state.password += digit; - if (state.password.length >= 4) { - if (verifyPassword(state.password)) { - passwordInputState.delete(userId); - if (await is2FAEnabled()) { - userStates.set(userId, { - state: "WAITING_2FA_LOGIN" /* WAITING_2FA_LOGIN */, - promptMessageId: update.msgId - }); - await client.editMessage(update.peer, { - message: update.msgId, - text: MSG.AUTH_2FA_PROMPT - }); - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_2FA_TOAST })); - return; - } - await persistAuthenticatedUser(userId); + state.password = (state.password + digit).slice(0, TELEGRAM_PIN_REQUIRED_LENGTH); + if (state.password.length >= TELEGRAM_PIN_REQUIRED_LENGTH) { + const pinOk = await verifyTelegramPin(state.password); + if (!pinOk) { + state.password = ""; + const failure = recordPinFailure(userId); + const text = failure.locked ? `\u274C \u5BC6\u7801\u9519\u8BEF\u6B21\u6570\u8FC7\u591A\uFF0C\u5DF2\u4E34\u65F6\u9501\u5B9A ${failure.retryAfterSeconds} \u79D2\u3002` : MSG.AUTH_WRONG; await client.editMessage(update.peer, { message: update.msgId, - text: buildAuthSuccess() + text, + buttons: generatePasswordKeyboard(0) }); - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_SUCCESS })); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: failure.locked ? "\u5DF2\u4E34\u65F6\u9501\u5B9A" : "\u5BC6\u7801\u9519\u8BEF", + alert: failure.locked + })); return; } - } - if (state.password.length >= 12) { - state.password = ""; + const allowedUsers = await getConfiguredTelegramAllowedUsers(); + if (allowedUsers.length > 0 && !allowedUsers.includes(userId)) { + state.password = ""; + await client.editMessage(update.peer, { + message: update.msgId, + text: "\u26D4 \u5F53\u524D Telegram \u7528\u6237\u4E0D\u5728\u5141\u8BB8\u5217\u8868\u4E2D\uFF0C\u8BF7\u5728 TELEGRAM_ALLOWED_USER_IDS \u6216\u540E\u53F0\u5141\u8BB8\u5217\u8868\u4E2D\u52A0\u5165\u4F60\u7684 user id\u3002", + buttons: generatePasswordKeyboard(0) + }); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u672A\u5728\u5141\u8BB8\u5217\u8868\u4E2D", alert: true })); + return; + } + clearPinFailures(userId); + passwordInputState.delete(userId); + if (await is2FAEnabled()) { + userStates.set(userId, { + state: "WAITING_2FA_LOGIN" /* WAITING_2FA_LOGIN */, + promptMessageId: update.msgId + }); + await client.editMessage(update.peer, { + message: update.msgId, + text: MSG.AUTH_2FA_PROMPT + }); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_2FA_TOAST })); + return; + } + await persistAuthenticatedUser(userId); await client.editMessage(update.peer, { message: update.msgId, - text: MSG.AUTH_WRONG, - buttons: generatePasswordKeyboard(0) + text: buildAuthSuccess() }); - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_WRONG })); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_SUCCESS })); return; } } @@ -6369,17 +7171,22 @@ async function handlePasswordCallback(update) { text: MSG.AUTH_INPUT_PROMPT, buttons: generatePasswordKeyboard(state.password.length) }); - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId })); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId })); } catch (error) { console.error("\u{1F916} \u5904\u7406\u5BC6\u7801\u56DE\u8C03\u5931\u8D25:", error); try { - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId })); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId })); } catch (e) { } } } async function handleCleanupButtonCallback(update, cleanupId) { if (!client) return; + const userId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(userId)) { + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + return; + } try { const result = await handleCleanupCallback(cleanupId); try { @@ -6390,14 +7197,14 @@ async function handleCleanupButtonCallback(update, cleanupId) { } catch (e) { console.error("\u{1F916} \u66F4\u65B0\u6E05\u7406\u7ED3\u679C\u6D88\u606F\u5931\u8D25:", e); } - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: result.success ? "\u2705 \u6E05\u7406\u6210\u529F" : "\u274C \u6E05\u7406\u5931\u8D25" })); } catch (error) { console.error("\u{1F916} \u5904\u7406\u6E05\u7406\u56DE\u8C03\u5931\u8D25:", error); try { - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u274C \u6E05\u7406\u5931\u8D25" })); @@ -6408,8 +7215,8 @@ async function handleCleanupButtonCallback(update, cleanupId) { async function handleTaskQueueCallback(update, data) { if (!client) return; const userId = update.userId.toJSNumber(); - if (!isAuthenticated(userId)) { - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ + if (!await isAuthenticatedAsync(userId)) { + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true @@ -6419,10 +7226,11 @@ async function handleTaskQueueCallback(update, data) { const match = data.match(/^tq_(pause|resume|cancel)_(.+)$/); if (!match) return; const [, action, taskId] = match; - if (!resolveTaskChatIdForControl(taskId)) { - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ + const controlChatId = resolveTaskChatIdForControl(taskId); + if (!controlChatId || !canControlTask(taskId, controlChatId, userId)) { + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, - message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u6216\u5DF2\u5931\u6548", + message: "\u4EFB\u52A1\u5DF2\u5B8C\u6210\u3001\u5DF2\u5931\u6548\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D\u804A\u5929", alert: true })); return; @@ -6431,25 +7239,106 @@ async function handleTaskQueueCallback(update, data) { if (action === "pause") { pauseDownloadTasks(taskId); await refreshSilentProgress(client, update.peer); - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u6682\u505C\u4E0B\u8F7D\u961F\u5217" })); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u6682\u505C\u5168\u5C40\u4E0B\u8F7D\u961F\u5217" })); return; } if (action === "resume") { resumeDownloadTasks(taskId); await refreshSilentProgress(client, update.peer); - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u7EE7\u7EED\u4E0B\u8F7D\u961F\u5217" })); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u7EE7\u7EED\u5168\u5C40\u4E0B\u8F7D\u961F\u5217" })); return; } - await cancelSilentTask(client, update.peer, taskId, update.msgId); - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88\u540E\u53F0\u4EFB\u52A1", alert: true })); + await cancelSilentTask(client, update.peer, taskId, update.msgId, userId); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88\u540E\u53F0\u4EFB\u52A1", alert: true })); } catch (error) { - await client.invoke(new Api4.messages.SetBotCallbackAnswer({ + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: `\u64CD\u4F5C\u5931\u8D25: ${error.message}`, alert: true })); } } +async function handleTelegramDownloadModeCallback(update, data) { + if (!client) return; + const userId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(userId)) { + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + return; + } + if (data === "tgd_cancel") { + telegramWizardStates.delete(userId); + await client.editMessage(update.peer, { message: update.msgId, text: "\u5DF2\u53D6\u6D88\u9891\u9053\u6587\u4EF6\u4E0B\u8F7D\u5411\u5BFC\u3002" }); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88" })); + return; + } + const state = telegramWizardStates.get(userId) || { kind: "tg_download", step: "mode" }; + if (data === "tgd_mode_date") state.kind = "tg_date"; + if (data === "tgd_mode_tag") state.kind = "tg_tag"; + state.step = "source"; + telegramWizardStates.set(userId, state); + await client.editMessage(update.peer, { message: update.msgId, text: buildTelegramWizardPrompt(state) }); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: data === "tgd_mode_date" ? "\u6309\u65E5\u671F\u4E0B\u8F7D" : "\u6309\u6807\u7B7E\u4E0B\u8F7D" })); +} +async function handleTelegramSubscriptionCallback(update, data) { + if (!client) return; + const userId = update.userId.toJSNumber(); + if (!await isAuthenticatedAsync(userId)) { + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: MSG.AUTH_REQUIRED, alert: true })); + return; + } + const match = data.match(/^tsub_(view|folder|clear|cancel)_(.+)$/); + if (!match) return; + const [, action, id] = match; + const rows = await listTelegramSubscriptions(userId); + const target = rows.find((row) => String(row.id) === id); + if (!target) { + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BA2\u9605\u4E0D\u5B58\u5728\u6216\u5DF2\u53D6\u6D88", alert: true })); + return; + } + if (action === "view") { + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ + queryId: update.queryId, + message: target.folder_override ? `\u4E13\u5C5E\u76EE\u5F55\uFF1A${target.folder_override}` : "\u5F53\u524D\u4F7F\u7528\u9ED8\u8BA4\u4FDD\u5B58\u8DEF\u5F84", + alert: true + })); + return; + } + if (action === "folder") { + const state = { + kind: "tg_sub_manage", + step: "path", + source: target.source, + subscriptionId: target.id, + subscriptionTitle: target.title, + subscriptionSource: target.source + }; + telegramWizardStates.set(userId, state); + await client.sendMessage(update.peer, { message: buildTelegramWizardPrompt(state) }); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u8BF7\u53D1\u9001\u65B0\u7684\u4E13\u5C5E\u76EE\u5F55" })); + return; + } + if (action === "clear") { + await updateTelegramSubscriptionFolder(userId, id, null); + const rowsAfterClear = await listTelegramSubscriptions(userId); + await client.editMessage(update.peer, { + message: update.msgId, + text: buildSubscriptionManagePanel(rowsAfterClear), + buttons: buildSubscriptionActionKeyboard(rowsAfterClear) + }); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u6E05\u9664\u4E13\u5C5E\u76EE\u5F55" })); + return; + } + if (action === "cancel") { + await unsubscribeTelegramChannel(userId, id); + const rowsAfterCancel = await listTelegramSubscriptions(userId); + await client.editMessage(update.peer, { + message: update.msgId, + text: buildSubscriptionManagePanel(rowsAfterCancel), + buttons: buildSubscriptionActionKeyboard(rowsAfterCancel) + }); + await client.invoke(new Api5.messages.SetBotCallbackAnswer({ queryId: update.queryId, message: "\u5DF2\u53D6\u6D88\u8BA2\u9605", alert: true })); + } +} async function initTelegramBot() { const apiId = parseInt(process.env.TELEGRAM_API_ID || "0"); const apiHash = process.env.TELEGRAM_API_HASH || ""; @@ -6468,13 +7357,13 @@ async function initTelegramBot() { console.error("\u{1F916} Telegram Bot \u540C\u6B65\u5B58\u50A8\u914D\u7F6E\u5931\u8D25:", e); } try { - const sessionDir = path13.dirname(SESSION_FILE); - if (!fs10.existsSync(sessionDir)) { - fs10.mkdirSync(sessionDir, { recursive: true }); + const sessionDir = path15.dirname(SESSION_FILE); + if (!fs11.existsSync(sessionDir)) { + fs11.mkdirSync(sessionDir, { recursive: true, mode: 448 }); } let sessionString = ""; - if (fs10.existsSync(SESSION_FILE)) { - sessionString = fs10.readFileSync(SESSION_FILE, "utf-8").trim(); + if (fs11.existsSync(SESSION_FILE)) { + sessionString = fs11.readFileSync(SESSION_FILE, "utf-8").trim(); } const session = new StringSession2(sessionString); client = new TelegramClient4(session, apiId, apiHash, { @@ -6491,7 +7380,12 @@ async function initTelegramBot() { botAuthToken: botToken }); const newSession = client.session.save(); - fs10.writeFileSync(SESSION_FILE, newSession); + fs11.writeFileSync(SESSION_FILE, newSession, { mode: 384 }); + try { + fs11.chmodSync(SESSION_FILE, 384); + } catch (e) { + console.warn("\u{1F916} \u4FEE\u6B63 Telegram Bot session \u6587\u4EF6\u6743\u9650\u5931\u8D25:", e); + } console.log("\u{1F916} Telegram Bot \u5DF2\u8FDE\u63A5!"); try { await query(` @@ -6505,26 +7399,22 @@ async function initTelegramBot() { console.error("\u{1F916} \u521D\u59CB\u5316 Telegram \u8BA4\u8BC1\u8868\u5931\u8D25:", e); } try { - await client.invoke(new Api4.bots.SetBotCommands({ - scope: new Api4.BotCommandScopeDefault(), + await client.invoke(new Api5.bots.SetBotCommands({ + scope: new Api5.BotCommandScopeDefault(), langCode: "zh", commands: [ - new Api4.BotCommand({ command: "start", description: "\u5F00\u59CB\u4F7F\u7528 / \u9A8C\u8BC1\u8EAB\u4EFD" }), - new Api4.BotCommand({ command: "setup_2fa", description: "\u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1 (2FA)" }), - new Api4.BotCommand({ command: "ytdlp", description: "\u89E3\u6790\u5E76\u4E0B\u8F7D\u94FE\u63A5\u5230\u5B58\u50A8\u6E90" }), - new Api4.BotCommand({ command: "tg_sub", description: "\u8BA2\u9605\u9891\u9053\u81EA\u52A8\u540C\u6B65" }), - new Api4.BotCommand({ command: "tg_date", description: "\u6309\u65E5\u671F\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6" }), - new Api4.BotCommand({ command: "tg_tag", description: "\u6309\u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6" }), - new Api4.BotCommand({ command: "tg_jobs", description: "\u67E5\u770B Telegram \u540E\u53F0\u4EFB\u52A1" }), - new Api4.BotCommand({ command: "storage", description: "\u67E5\u770B\u5B58\u50A8\u7EDF\u8BA1" }), - new Api4.BotCommand({ command: "list", description: "\u67E5\u770B\u4E0A\u4F20\u8BB0\u5F55" }), - new Api4.BotCommand({ command: "tasks", description: "\u67E5\u770B\u4EFB\u52A1\u72B6\u6001" }), - new Api4.BotCommand({ command: "stop_tasks", description: "\u5F3A\u5236\u505C\u6B62\u4E0B\u8F7D\u4EFB\u52A1" }), - new Api4.BotCommand({ command: "download_workers", description: "\u8BBE\u7F6E Telegram \u5E76\u53D1\u4E0B\u8F7D" }), - new Api4.BotCommand({ command: "path_rules", description: "\u8BBE\u7F6E\u4FDD\u5B58\u8DEF\u5F84\u89C4\u5219" }), - new Api4.BotCommand({ command: "duplicate_mode", description: "\u8BBE\u7F6E\u91CD\u590D\u6587\u4EF6\u5904\u7406" }), - new Api4.BotCommand({ command: "cleanup_settings", description: "\u8BBE\u7F6E\u81EA\u52A8\u6E05\u7406\u5F00\u5173" }), - new Api4.BotCommand({ command: "help", description: "\u663E\u793A\u9884\u89C8\u5E2E\u52A9" }) + new Api5.BotCommand({ command: "start", description: "\u5F00\u59CB\u4F7F\u7528 / \u9A8C\u8BC1\u8EAB\u4EFD" }), + new Api5.BotCommand({ command: "path_rules", description: "\u4FDD\u5B58\u8DEF\u5F84/\u81EA\u5B9A\u4E49\u76EE\u5F55" }), + new Api5.BotCommand({ command: "tg_sub", description: "\u8BA2\u9605\u9891\u9053\u81EA\u52A8\u540C\u6B65" }), + new Api5.BotCommand({ command: "tg_download", description: "\u6309\u65E5\u671F/\u6807\u7B7E\u4E0B\u8F7D\u9891\u9053\u6587\u4EF6" }), + new Api5.BotCommand({ command: "download_workers", description: "\u8BBE\u7F6E Telegram \u5E76\u53D1\u4E0B\u8F7D" }), + new Api5.BotCommand({ command: "duplicate_mode", description: "\u8BBE\u7F6E\u91CD\u590D\u6587\u4EF6\u5904\u7406" }), + new Api5.BotCommand({ command: "cleanup_settings", description: "\u8BBE\u7F6E\u81EA\u52A8\u6E05\u7406\u5F00\u5173" }), + new Api5.BotCommand({ command: "storage", description: "\u67E5\u770B\u5B58\u50A8\u7EDF\u8BA1/\u6E05\u7406\u672C\u5730\u6587\u4EF6" }), + new Api5.BotCommand({ command: "tasks", description: "\u67E5\u770B\u4EFB\u52A1\u72B6\u6001" }), + new Api5.BotCommand({ command: "setup_2fa", description: "\u914D\u7F6E\u53CC\u91CD\u9A8C\u8BC1 (2FA)" }), + new Api5.BotCommand({ command: "ytdlp", description: "\u89E3\u6790\u5E76\u4E0B\u8F7D\u94FE\u63A5\u5230\u5B58\u50A8\u6E90" }), + new Api5.BotCommand({ command: "help", description: "\u663E\u793A\u9884\u89C8\u5E2E\u52A9" }) ] })); console.log("\u{1F916} Bot \u547D\u4EE4\u83DC\u5355\u5DF2\u66F4\u65B0"); @@ -6585,7 +7475,7 @@ async function initTelegramBot() { console.log(`\u{1F916} Received text from ${senderId}: ${text}`); if (text === "/start") { await handleStart(message, senderId); - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: buildStartPrompt(), buttons: generatePasswordKeyboard(0) @@ -6594,12 +7484,16 @@ async function initTelegramBot() { return; } if (text === "/setup_2fa" || text === "/setup-2fa") { + if (!await isAuthenticatedAsync(senderId)) { + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } try { const qrDataUrl = await generateOTPAuthUrl(); const base64Data = qrDataUrl.replace(/^data:image\/png;base64,/, ""); const buffer = Buffer.from(base64Data, "base64"); - const tempPath = path13.join(process.cwd(), `temp_qr_${senderId}_${Date.now()}_${Math.random().toString(36).slice(2)}.png`); - fs10.writeFileSync(tempPath, buffer); + const tempPath = path15.join(process.cwd(), `temp_qr_${senderId}_${Date.now()}_${Math.random().toString(36).slice(2)}.png`); + fs11.writeFileSync(tempPath, buffer); const qrMessage = await client.sendFile(chatId, { file: tempPath, caption: build2FASetupCaption() @@ -6608,7 +7502,7 @@ async function initTelegramBot() { state: "WAITING_2FA_SETUP" /* WAITING_2FA_SETUP */, qrMessageId: qrMessage.id }); - fs10.unlinkSync(tempPath); + fs11.unlinkSync(tempPath); } catch (e) { console.error("\u751F\u6210 2FA \u4E8C\u7EF4\u7801\u5931\u8D25:", e); await client.sendMessage(chatId, { message: MSG.AUTH_2FA_QR_FAIL }); @@ -6623,7 +7517,7 @@ async function initTelegramBot() { const match = text.match(/^\s*\/ytdlp(?:@\w+)?(?:\s+([\s\S]*))?\s*$/i); if (match) { console.log(`\u{1F916} /ytdlp command received from ${senderId}: ${text}`); - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6649,15 +7543,23 @@ async function initTelegramBot() { } } if (text === "/tg_sub" || text === "/tg_subscribe") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } await startTelegramWizard(message, senderId, "tg_sub_manage"); return; } + if (text === "/tg_download" || text === "/tg_dl") { + if (!await isAuthenticatedAsync(senderId)) { + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } + await startTelegramWizard(message, senderId, "tg_download"); + return; + } if (text === "/tg_date") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6665,7 +7567,7 @@ async function initTelegramBot() { return; } if (text === "/tg_tag") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6673,11 +7575,40 @@ async function initTelegramBot() { return; } if (!text.startsWith("/")) { + if (isCancelInput(text)) { + const pendingMode = getPendingTelegramPathInput(chatId.toString(), senderId); + if (pendingMode) { + clearPendingTelegramPathInput(chatId.toString(), senderId); + await message.reply({ message: "\u5DF2\u53D6\u6D88\u4FDD\u5B58\u8DEF\u5F84\u8BBE\u7F6E\u3002" }); + return; + } + } else { + try { + const appliedPath = await applyPendingTelegramPathInputPersistent(chatId.toString(), senderId, text); + if (appliedPath) { + await message.reply({ + message: appliedPath.mode === "once" ? `\u{1F4CC} \u5DF2\u8BBE\u7F6E\u4E0B\u4E00\u6B21\u4E0B\u8F7D\u76EE\u5F55\uFF1A\`${appliedPath.folder}\` +${buildPathPreviewLine(appliedPath.folder)} + +\u6B64\u8BBE\u7F6E\u4F1A\u5728\u4E0B\u4E00\u6B21\u6210\u529F\u8FDB\u5165\u4E0B\u8F7D\u6D41\u7A0B\u65F6\u81EA\u52A8\u5931\u6548\u3002` : `\u{1F4CD} \u5DF2\u8BBE\u7F6E\u672C\u4F1A\u8BDD\u4E0B\u8F7D\u76EE\u5F55\uFF1A\`${appliedPath.folder}\` +${buildPathPreviewLine(appliedPath.folder)} + +\u540E\u7EED\u6B64\u804A\u5929\u4E2D\u7684\u4E0B\u8F7D\u4F1A\u4F18\u5148\u4FDD\u5B58\u5230\u8BE5\u76EE\u5F55\uFF0C\u53D1\u9001 /pc \u53EF\u6E05\u9664\u3002` + }); + return; + } + } catch (error) { + await message.reply({ message: `\u274C \u8DEF\u5F84\u65E0\u6548\uFF1A${error.message} + +\u8BF7\u91CD\u65B0\u53D1\u9001\u76EE\u5F55\uFF0C\u6216\u53D1\u9001\u201C\u53D6\u6D88\u201D\u9000\u51FA\u672C\u6B21\u8BBE\u7F6E\u3002` }); + return; + } + } const handledTelegramWizard = await handleTelegramWizardMessage(message, senderId, text); if (handledTelegramWizard) return; } if (text === "/tg_subs" || text === "/tg_subscriptions") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6686,7 +7617,7 @@ async function initTelegramBot() { return; } if (text.startsWith("/tg_sub ") || text.startsWith("/tg_subscribe ")) { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6696,7 +7627,7 @@ async function initTelegramBot() { return; } try { - const sub = await subscribeTelegramChannel(senderId, chatId.toString(), source); + const sub = await subscribeTelegramChannel(senderId, chatId.toString(), source, null); await message.reply({ message: `\u2705 \u5DF2\u8BA2\u9605 ${sub.title || sub.source} \u{1F4CD} ${sub.source} \u4ECE\u5F53\u524D\u6700\u65B0\u6D88\u606F ID ${sub.last_message_id || 0} \u4E4B\u540E\u5F00\u59CB\u81EA\u52A8\u540C\u6B65\u3002` }); @@ -6706,7 +7637,7 @@ async function initTelegramBot() { return; } if (text.startsWith("/tg_unsub ") || text.startsWith("/tg_unsubscribe ")) { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6719,8 +7650,46 @@ async function initTelegramBot() { await message.reply({ message: sub ? `\u2705 \u5DF2\u53D6\u6D88\u8BA2\u9605 ${sub.title || sub.source}` : "\u274C \u672A\u627E\u5230\u8BE5\u8BA2\u9605" }); return; } + if (text.startsWith("/tg_download ") || text.startsWith("/tg_dl ")) { + if (!await isAuthenticatedAsync(senderId)) { + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } + const parts = text.split(/\s+/).slice(1); + const mode = (parts.shift() || "").toLowerCase(); + if (mode === "date" || mode === "\u65E5\u671F") { + if (parts.length !== 3) { + await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/tg_download date @\u9891\u9053 YYYY-MM-DD YYYY-MM-DD" }); + return; + } + try { + const queuedMsg = await message.reply({ message: `\u23F3 \u5DF2\u5F00\u59CB\u540E\u53F0\u626B\u63CF ${parts[0]}\uFF1A${parts[1]} \u2192 ${parts[2]}... +\u5B8C\u6210\u540E\u4F1A\u81EA\u52A8\u66F4\u65B0\u7ED3\u679C\uFF0C\u53EF\u7528 /tasks \u67E5\u770B\u540E\u53F0\u4EFB\u52A1\u3002` }); + await replyWithJobResult(queuedMsg, message, enqueueTelegramDateDownload(client, message, senderId, parts[0], parts[1], parts[2]), "date"); + } catch (error) { + await message.reply({ message: `\u274C \u65E5\u671F\u4E0B\u8F7D\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` }); + } + return; + } + if (mode === "tag" || mode === "\u6807\u7B7E") { + if (parts.length !== 2) { + await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/tg_download tag @\u9891\u9053 #\u6807\u7B7E" }); + return; + } + try { + const queuedMsg = await message.reply({ message: `\u23F3 \u5DF2\u5F00\u59CB\u540E\u53F0\u626B\u63CF ${parts[0]} \u4E2D\u5E26\u6709 ${parts[1].startsWith("#") ? parts[1] : `#${parts[1]}`} \u7684\u5A92\u4F53\u6D88\u606F... +\u5B8C\u6210\u540E\u4F1A\u81EA\u52A8\u66F4\u65B0\u7ED3\u679C\uFF0C\u53EF\u7528 /tasks \u67E5\u770B\u540E\u53F0\u4EFB\u52A1\u3002` }); + await replyWithJobResult(queuedMsg, message, enqueueTelegramTagDownload(client, message, senderId, parts[0], parts[1]), "tag"); + } catch (error) { + await message.reply({ message: `\u274C \u6807\u7B7E\u4E0B\u8F7D\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` }); + } + return; + } + await message.reply({ message: "\u274C \u7528\u6CD5\uFF1A/tg_download date @\u9891\u9053 YYYY-MM-DD YYYY-MM-DD\n\u6216\uFF1A/tg_download tag @\u9891\u9053 #\u6807\u7B7E" }); + return; + } if (text.startsWith("/tg_date ")) { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6730,19 +7699,16 @@ async function initTelegramBot() { return; } try { - await message.reply({ message: `\u23F3 \u6B63\u5728\u6309\u65E5\u671F\u626B\u63CF ${parts[0]}\uFF1A${parts[1]} \u2192 ${parts[2]}...` }); - const result = await enqueueTelegramDateDownload(client, message, senderId, parts[0], parts[1], parts[2]); - await message.reply({ message: `\u2705 \u65E5\u671F\u8303\u56F4\u4EFB\u52A1\u5DF2\u63D0\u4EA4 -ID: ${String(result.jobId).slice(0, 8)} -\u5165\u961F: ${result.found} -\u8DF3\u8FC7: ${result.skipped}` }); + const queuedMsg = await message.reply({ message: `\u23F3 \u5DF2\u5F00\u59CB\u540E\u53F0\u626B\u63CF ${parts[0]}\uFF1A${parts[1]} \u2192 ${parts[2]}... +\u5B8C\u6210\u540E\u4F1A\u81EA\u52A8\u66F4\u65B0\u7ED3\u679C\uFF0C\u53EF\u7528 /tasks \u67E5\u770B\u540E\u53F0\u4EFB\u52A1\u3002` }); + await replyWithJobResult(queuedMsg, message, enqueueTelegramDateDownload(client, message, senderId, parts[0], parts[1], parts[2]), "date"); } catch (error) { await message.reply({ message: `\u274C \u65E5\u671F\u4E0B\u8F7D\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` }); } return; } if (text.startsWith("/tg_tag ")) { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6752,29 +7718,16 @@ ID: ${String(result.jobId).slice(0, 8)} return; } try { - await message.reply({ message: `\u23F3 \u6B63\u5728\u626B\u63CF ${parts[0]} \u4E2D\u5E26\u6709 ${parts[1].startsWith("#") ? parts[1] : `#${parts[1]}`} \u7684\u5A92\u4F53\u6D88\u606F...` }); - const result = await enqueueTelegramTagDownload(client, message, senderId, parts[0], parts[1]); - await message.reply({ message: `\u2705 \u6807\u7B7E\u4E0B\u8F7D\u4EFB\u52A1\u5DF2\u63D0\u4EA4 -\u6807\u7B7E: ${result.tag} -ID: ${String(result.jobId).slice(0, 8)} -\u5165\u961F: ${result.found} -\u8DF3\u8FC7: ${result.skipped}` }); + const queuedMsg = await message.reply({ message: `\u23F3 \u5DF2\u5F00\u59CB\u540E\u53F0\u626B\u63CF ${parts[0]} \u4E2D\u5E26\u6709 ${parts[1].startsWith("#") ? parts[1] : `#${parts[1]}`} \u7684\u5A92\u4F53\u6D88\u606F... +\u5B8C\u6210\u540E\u4F1A\u81EA\u52A8\u66F4\u65B0\u7ED3\u679C\uFF0C\u53EF\u7528 /tasks \u67E5\u770B\u540E\u53F0\u4EFB\u52A1\u3002` }); + await replyWithJobResult(queuedMsg, message, enqueueTelegramTagDownload(client, message, senderId, parts[0], parts[1]), "tag"); } catch (error) { await message.reply({ message: `\u274C \u6807\u7B7E\u4E0B\u8F7D\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` }); } return; } - if (text === "/tg_jobs" || text === "/tg_tasks") { - if (!isAuthenticated(senderId)) { - await message.reply({ message: MSG.AUTH_REQUIRED }); - return; - } - const jobs = await listTelegramBackgroundJobs(senderId); - await message.reply({ message: formatJobList(jobs) }); - return; - } if (text === "/storage") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6782,16 +7735,11 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/list" || text.startsWith("/list ")) { - if (!isAuthenticated(senderId)) { - await message.reply({ message: MSG.AUTH_REQUIRED }); - return; - } - const args = text.split(" ").slice(1); - await handleList(message, args); + await message.reply({ message: "\u{1F4CB} \u4E0A\u4F20\u8BB0\u5F55\u83DC\u5355\u5DF2\u9690\u85CF\u3002\u9700\u8981\u67E5\u770B\u6587\u4EF6\u65F6\u8BF7\u5230\u7F51\u9875\u7AEF\u6587\u4EF6\u5217\u8868\uFF0C\u6216\u4F7F\u7528 /storage \u67E5\u770B\u7EDF\u8BA1\u3002" }); return; } if (text.startsWith("/delete ")) { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6800,7 +7748,7 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/tasks" || text === "/task") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6808,7 +7756,7 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/task_pause" || text.startsWith("/task_pause ")) { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6816,7 +7764,7 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/task_resume" || text.startsWith("/task_resume ")) { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6824,7 +7772,7 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/task_cancel" || text.startsWith("/task_cancel ")) { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6832,7 +7780,7 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/tg_retry" || text.startsWith("/tg_retry ")) { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6840,7 +7788,7 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/stop_tasks" || text === "/stop" || text === "/cancel_tasks") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6848,7 +7796,7 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/download_workers" || text === "/workers") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6856,15 +7804,39 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/path_rules" || text === "/path" || text === "/save_rules") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } await handlePathRules(message); return; } + if (text === "/pc") { + if (!await isAuthenticatedAsync(senderId)) { + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } + await handlePathClear(message); + return; + } + if (text.startsWith("/p ")) { + if (!await isAuthenticatedAsync(senderId)) { + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } + await handlePathOnce(message, text.split(/\s+/).slice(1)); + return; + } + if (text.startsWith("/ps ")) { + if (!await isAuthenticatedAsync(senderId)) { + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } + await handlePathSession(message, text.split(/\s+/).slice(1)); + return; + } if (text === "/duplicate_mode" || text === "/duplicate" || text === "/dup") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6872,7 +7844,7 @@ ID: ${String(result.jobId).slice(0, 8)} return; } if (text === "/cleanup_settings" || text === "/cleanup") { - if (!isAuthenticated(senderId)) { + if (!await isAuthenticatedAsync(senderId)) { await message.reply({ message: MSG.AUTH_REQUIRED }); return; } @@ -6886,6 +7858,11 @@ ID: ${String(result.jobId).slice(0, 8)} const verified = await verifyTOTP(cleanText); if (verified) { if (userState.state === "WAITING_2FA_SETUP" /* WAITING_2FA_SETUP */) { + if (!await isAuthenticatedAsync(senderId)) { + userStates.delete(senderId); + await message.reply({ message: MSG.AUTH_REQUIRED }); + return; + } await activate2FA(); await message.reply({ message: MSG.AUTH_2FA_ACTIVATED }); } else { @@ -6915,7 +7892,7 @@ ID: ${String(result.jobId).slice(0, 8)} if (message.media) { await handleFileUpload(client, event); } - if (!isAuthenticated(senderId) && text && !text.startsWith("/")) { + if (!await isAuthenticatedAsync(senderId) && text && !text.startsWith("/")) { await message.reply({ message: MSG.UNKNOWN_TEXT }); } } catch (error) { @@ -6940,6 +7917,14 @@ ID: ${String(result.jobId).slice(0, 8)} await handleDownloadWorkersCallback(activeClient, callbackUpdate, data); return; } + if (data.startsWith("storage_")) { + await handleStorageCleanupCallback(activeClient, callbackUpdate, data); + return; + } + if (data.startsWith("del_")) { + await handleDeleteConfirmCallback(activeClient, callbackUpdate, data); + return; + } if (data.startsWith("pr_")) { await handlePathRulesCallback(activeClient, callbackUpdate, data); return; @@ -6948,6 +7933,14 @@ ID: ${String(result.jobId).slice(0, 8)} await handleDuplicateModeCallback(activeClient, callbackUpdate, data); return; } + if (data.startsWith("tgd_")) { + await handleTelegramDownloadModeCallback(callbackUpdate, data); + return; + } + if (data.startsWith("tsub_")) { + await handleTelegramSubscriptionCallback(callbackUpdate, data); + return; + } if (data.startsWith("tq_")) { await handleTaskQueueCallback(callbackUpdate, data); return; @@ -7012,6 +8005,11 @@ async function sendLoginNotification(req) { } var router = Router(); var SIGNED_URL_TYPES = /* @__PURE__ */ new Set(["preview", "thumbnail", "download"]); +var MAX_SIGNED_URL_EXPIRES_IN_SECONDS = { + thumbnail: 24 * 60 * 60, + preview: 60 * 60, + download: 60 * 60 +}; function normalizeSignedUrlType(value) { if (typeof value !== "string") return null; return SIGNED_URL_TYPES.has(value) ? value : null; @@ -7027,7 +8025,7 @@ function setAuthCookie(res, token, expiresAt) { 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: "/" }); @@ -7044,24 +8042,20 @@ setInterval(() => { } }); }, 60 * 60 * 1e3); -function hashPassword(password) { - return crypto7.createHash("sha256").update(password).digest("hex"); -} function generateToken() { return crypto7.randomBytes(32).toString("hex"); } -function verifyPassword2(password) { - 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 && crypto7.timingSafeEqual(input, expected); - } catch { - return false; - } +function issueSession(req, res) { + const token = generateToken(); + const now = /* @__PURE__ */ new Date(); + const expiresAt = new Date(now.getTime() + TOKEN_EXPIRY); + sessions.set(token, { createdAt: now, expiresAt }); + sendLoginNotification(req).catch((error) => console.warn("\u53D1\u9001\u767B\u5F55\u901A\u77E5\u5931\u8D25:", error)); + setAuthCookie(res, token, expiresAt); + res.json({ + success: true, + expiresAt: expiresAt.toISOString() + }); } var loginLimiter = rateLimit({ windowMs: 15 * 60 * 1e3, @@ -7071,40 +8065,49 @@ var loginLimiter = rateLimit({ legacyHeaders: false, keyGenerator: (req) => getClientIP(req) }); +router.post("/setup", loginLimiter, async (req, res) => { + try { + if (!await isInitialSetupRequired()) { + return res.status(409).json({ error: "\u7BA1\u7406\u5458\u5BC6\u7801\u5DF2\u521B\u5EFA" }); + } + 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("\u521D\u59CB\u5316\u7BA1\u7406\u5458\u5931\u8D25:", error); + res.status(500).json({ error: error instanceof Error ? error.message : "\u521D\u59CB\u5316\u7BA1\u7406\u5458\u5931\u8D25" }); + } +}); router.post("/login", loginLimiter, async (req, res) => { const { password } = req.body; + if (await isInitialSetupRequired()) { + return res.status(428).json({ error: "\u8BF7\u5148\u521B\u5EFA\u7BA1\u7406\u5458\u5BC6\u7801", setupRequired: true }); + } if (!password) { return res.status(400).json({ error: "\u8BF7\u8F93\u5165\u5BC6\u7801" }); } - if (!verifyPassword2(password)) { + if (!await verifyWebPassword(password)) { return res.status(401).json({ error: "\u8BA4\u8BC1\u5931\u8D25" }); } if (await is2FAEnabled()) { return res.json({ success: true, requiresTOTP: true, - // 暂时不生成完整 token,只在 TOTP 验证后返回 message: "\u8BF7\u8F93\u5165\u4E8C\u6B21\u9A8C\u8BC1\u7801" }); } - const token = generateToken(); - const now = /* @__PURE__ */ 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); }); router.post("/verify-totp", loginLimiter, async (req, res) => { const { password, totpToken } = req.body; if (!password || !totpToken) { return res.status(400).json({ error: "\u53C2\u6570\u4E0D\u5B8C\u6574" }); } - if (!verifyPassword2(password)) { + if (!await verifyWebPassword(password)) { return res.status(401).json({ error: "\u8BA4\u8BC1\u5931\u8D25" }); } if (!await verifyTOTP(totpToken)) { @@ -7118,7 +8121,6 @@ router.post("/verify-totp", loginLimiter, async (req, res) => { setAuthCookie(res, token, expiresAt); res.json({ success: true, - token, expiresAt: expiresAt.toISOString() }); }); @@ -7149,7 +8151,7 @@ router.post("/2fa-activate", requireAuth, async (req, res) => { router.post("/2fa-disable", requireAuth, async (req, res) => { const { password } = req.body; if (!password) return res.status(400).json({ error: "\u8BF7\u8F93\u5165\u5BC6\u7801\u9A8C\u8BC1" }); - if (!verifyPassword2(password)) { + if (!await verifyWebPassword(password)) { return res.status(401).json({ error: "\u8BA4\u8BC1\u5931\u8D25" }); } try { @@ -7160,7 +8162,10 @@ router.post("/2fa-disable", requireAuth, async (req, res) => { res.status(500).json({ error: "\u7981\u7528\u5931\u8D25" }); } }); -router.get("/verify", (req, res) => { +router.get("/verify", async (req, res) => { + if (await isInitialSetupRequired()) { + return res.status(428).json({ valid: false, setupRequired: true, error: "\u8BF7\u5148\u521B\u5EFA\u7BA1\u7406\u5458\u5BC6\u7801" }); + } const token = getAuthToken(req); if (!token) { return res.status(401).json({ valid: false, error: "\u672A\u63D0\u4F9B Token" }); @@ -7180,9 +8185,11 @@ router.post("/logout", (req, res) => { clearAuthCookie(res); res.json({ success: true }); }); -router.get("/status", (_req, res) => { +router.get("/status", async (_req, res) => { + const setupRequired = await isInitialSetupRequired(); res.json({ - passwordRequired: !!ACCESS_PASSWORD_HASH + setupRequired, + passwordRequired: true }); }); router.post("/sign-url", requireAuth, (req, res) => { @@ -7194,10 +8201,17 @@ router.post("/sign-url", requireAuth, (req, res) => { if (!signedType) { return res.status(400).json({ error: "\u7B7E\u540D\u7C7B\u578B\u65E0\u6548" }); } - const expiresInSeconds = Number(expiresIn); + const expiresInSeconds = Math.floor(Number(expiresIn)); if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) { return res.status(400).json({ error: "\u8FC7\u671F\u65F6\u95F4\u65E0\u6548" }); } + const maxExpiresIn = MAX_SIGNED_URL_EXPIRES_IN_SECONDS[signedType]; + if (expiresInSeconds > maxExpiresIn) { + return res.status(400).json({ + error: signedType === "thumbnail" ? "\u7F29\u7565\u56FE\u7B7E\u540D\u6709\u6548\u671F\u4E0D\u80FD\u8D85\u8FC7 24 \u5C0F\u65F6" : "\u9884\u89C8/\u4E0B\u8F7D\u7B7E\u540D\u6709\u6548\u671F\u4E0D\u80FD\u8D85\u8FC7 1 \u5C0F\u65F6", + maxExpiresIn + }); + } const expires = Date.now() + expiresInSeconds * 1e3; const sign = generateSignature(fileId, signedType, expires); res.json({ @@ -7207,9 +8221,9 @@ router.post("/sign-url", requireAuth, (req, res) => { type: signedType }); }); -function requireAuth(req, res, next) { - if (!ACCESS_PASSWORD_HASH) { - return next(); +async function requireAuth(req, res, next) { + if (await isInitialSetupRequired()) { + return res.status(428).json({ error: "\u8BF7\u5148\u521B\u5EFA\u7BA1\u7406\u5458\u5BC6\u7801", setupRequired: true }); } let token = getAuthToken(req); if (!token) { @@ -7296,8 +8310,44 @@ function requireAuthOrSignedUrl(req, res, next) { // src/routes/files.ts init_localPath(); var router2 = Router2(); -var UPLOAD_DIR4 = path14.resolve(process.env.UPLOAD_DIR || "./data/uploads"); -var THUMBNAIL_DIR4 = path14.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); +var UPLOAD_DIR5 = path16.resolve(process.env.UPLOAD_DIR || "./data/uploads"); +var THUMBNAIL_DIR5 = path16.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); +async function getSafeLocalFilePath(file) { + const candidate = file.path || path16.join(UPLOAD_DIR5, file.stored_name); + const resolved = path16.resolve(candidate); + if (!isPathInside(UPLOAD_DIR5, resolved)) { + throw new Error("Unsafe local file path"); + } + if (!fs12.existsSync(resolved)) { + return resolved; + } + const real = await fs12.promises.realpath(resolved); + if (!isPathInside(UPLOAD_DIR5, real)) { + throw new Error("Unsafe local file path"); + } + return real; +} +function parseRangeHeader(range, size) { + if (!range) return null; + const match = range.match(/^bytes=(\d*)-(\d*)$/); + if (!match) return null; + let start; + let end; + 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) }; +} router2.get("/", async (req, res) => { try { const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); @@ -7431,11 +8481,10 @@ router2.post("/folders/favorite", async (req, res) => { router2.get("/:id([0-9a-fA-F-]{36})", async (req, res) => { try { const { id } = req.params; - 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - const file = result.rows[0]; res.json({ ...file, size: formatFileSize(file.size), @@ -7451,11 +8500,10 @@ router2.get("/:id([0-9a-fA-F-]{36})", async (req, res) => { router2.get("/:id([0-9a-fA-F-]{36})/preview", async (req, res) => { try { const { id } = req.params; - 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - 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: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); @@ -7478,8 +8526,8 @@ router2.get("/:id([0-9a-fA-F-]{36})/preview", async (req, res) => { return res.status(500).json({ error: "\u83B7\u53D6\u9884\u89C8\u5931\u8D25" }); } } - const filePath = file.path || path14.join(UPLOAD_DIR4, file.stored_name); - if (!fs11.existsSync(filePath)) { + const filePath = await getSafeLocalFilePath(file); + if (!fs12.existsSync(filePath)) { return res.status(404).json({ error: "\u6587\u4EF6\u4E0D\u5B58\u5728\u4E8E\u670D\u52A1\u5668" }); } res.set({ @@ -7487,12 +8535,19 @@ router2.get("/:id([0-9a-fA-F-]{36})/preview", async (req, res) => { "Cache-Control": "public, max-age=86400", "ETag": `"${file.id}-${file.updated_at}"` }); - const stat = fs11.statSync(filePath); + const stat = fs12.statSync(filePath); 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); res.set({ @@ -7500,11 +8555,11 @@ router2.get("/:id([0-9a-fA-F-]{36})/preview", async (req, res) => { "Accept-Ranges": "bytes", "Content-Length": String(chunksize) }); - const stream = fs11.createReadStream(filePath, { start, end }); + const stream = fs12.createReadStream(filePath, { start, end }); stream.pipe(res); } else { res.set("Content-Length", String(stat.size)); - const stream = fs11.createReadStream(filePath); + const stream = fs12.createReadStream(filePath); stream.pipe(res); } } catch (error) { @@ -7515,11 +8570,10 @@ router2.get("/:id([0-9a-fA-F-]{36})/preview", async (req, res) => { router2.get("/:id([0-9a-fA-F-]{36})/download-url", async (req, res) => { try { const { id } = req.params; - 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - 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: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); @@ -7547,12 +8601,10 @@ router2.get("/:id([0-9a-fA-F-]{36})/download", async (req, res) => { try { const { id } = req.params; console.log(`[Download] Starting download for ID: ${id}`); - const result = await query("SELECT * FROM files WHERE id = $1", [id]); - if (result.rows.length === 0) { - console.log(`[Download] File not found in DB: ${id}`); + const file = await getScopedFileById(id); + if (!file) { return res.status(404).json({ error: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - 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: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); @@ -7572,9 +8624,9 @@ router2.get("/:id([0-9a-fA-F-]{36})/download", async (req, res) => { return res.status(500).json({ error: "\u65E0\u6CD5\u4E0B\u8F7D\u6587\u4EF6" }); } } - const filePath = file.path || path14.join(UPLOAD_DIR4, file.stored_name); + const filePath = await getSafeLocalFilePath(file); console.log(`[Download] Serving local file: ${filePath}`); - if (!fs11.existsSync(filePath)) { + if (!fs12.existsSync(filePath)) { console.log(`[Download] File system path not found: ${filePath}`); return res.status(404).json({ error: "\u6587\u4EF6\u4E0D\u5B58\u5728\u4E8E\u670D\u52A1\u5668" }); } @@ -7591,23 +8643,22 @@ router2.get("/:id([0-9a-fA-F-]{36})/download", async (req, res) => { router2.get("/:id([0-9a-fA-F-]{36})/thumbnail", async (req, res) => { try { const { id } = req.params; - 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - const file = result.rows[0]; if (!file.thumbnail_path) { return res.status(404).json({ error: "\u65E0\u7F29\u7565\u56FE" }); } - const thumbPath = path14.join(THUMBNAIL_DIR4, path14.basename(file.thumbnail_path)); - if (!fs11.existsSync(thumbPath)) { + const thumbPath = path16.join(THUMBNAIL_DIR5, path16.basename(file.thumbnail_path)); + if (!fs12.existsSync(thumbPath)) { return res.status(404).json({ error: "\u7F29\u7565\u56FE\u6587\u4EF6\u4E0D\u5B58\u5728" }); } res.set({ "Content-Type": "image/webp", "Cache-Control": "public, max-age=604800" }); - const stream = fs11.createReadStream(thumbPath); + const stream = fs12.createReadStream(thumbPath); stream.pipe(res); } catch (error) { console.error("\u83B7\u53D6\u7F29\u7565\u56FE\u5931\u8D25:", error); @@ -7617,26 +8668,14 @@ router2.get("/:id([0-9a-fA-F-]{36})/thumbnail", async (req, res) => { router2.delete("/:id([0-9a-fA-F-]{36})", async (req, res) => { try { const { id } = req.params; - 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - 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: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); - const provider = storageManager2.getProvider(`${file.source}:${file.storage_account_id}`); - await provider.deleteFile(file.path); - } catch (err) { - console.error(`${file.source} \u6587\u4EF6\u5220\u9664\u5931\u8D25 (\u53EF\u80FD\u5DF2\u4E0D\u5B58\u5728):`, err); - } - } else { - const filePath = file.path || path14.join(UPLOAD_DIR4, file.stored_name); - await safeUnlink(filePath, UPLOAD_DIR4); - } - if (file.thumbnail_path) { - const thumbPath = path14.join(THUMBNAIL_DIR4, path14.basename(file.thumbnail_path)); - await safeUnlink(thumbPath, THUMBNAIL_DIR4); + try { + await removePhysicalFile(file); + } catch (err) { + console.error(`\u7269\u7406\u6587\u4EF6\u5220\u9664\u5931\u8D25 (\u53EF\u80FD\u5DF2\u4E0D\u5B58\u5728):`, err); } await query("DELETE FROM files WHERE id = $1", [id]); res.json({ success: true, message: "\u6587\u4EF6\u5DF2\u5220\u9664" }); @@ -7645,55 +8684,6 @@ router2.delete("/:id([0-9a-fA-F-]{36})", async (req, res) => { res.status(500).json({ error: "\u5220\u9664\u6587\u4EF6\u5931\u8D25" }); } }); -router2.post("/batch-delete", async (req, res) => { - try { - const { fileIds = [], folderNames = [] } = req.body; - if (!Array.isArray(fileIds) || !Array.isArray(folderNames)) { - return res.status(400).json({ error: "\u53C2\u6570\u683C\u5F0F\u9519\u8BEF" }); - } - if (fileIds.length === 0 && folderNames.length === 0) { - return res.status(400).json({ error: "\u8BF7\u63D0\u4F9B\u8981\u5220\u9664\u7684\u6587\u4EF6\u6216\u6587\u4EF6\u5939" }); - } - let filesToDelete = []; - 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: "\u6CA1\u6709\u53D1\u73B0\u5F85\u5220\u9664\u7684\u9879\u76EE" }); - } - 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: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); - const provider = storageManager2.getProvider(`${file.source}:${file.storage_account_id}`); - await provider.deleteFile(file.path); - } else { - const filePath = file.path || path14.join(UPLOAD_DIR4, file.stored_name); - await safeUnlink(filePath, UPLOAD_DIR4); - } - if (file.thumbnail_path) { - const thumbPath = path14.join(THUMBNAIL_DIR4, path14.basename(file.thumbnail_path)); - await safeUnlink(thumbPath, THUMBNAIL_DIR4); - } - } catch (err) { - console.error(`\u5220\u9664\u7269\u7406\u6587\u4EF6\u5931\u8D25 (ID: ${file.id}):`, err); - } - }); - await Promise.all(storagePromises); - const idsToDelete = uniqueFiles.map((f) => f.id); - await query("DELETE FROM files WHERE id = ANY($1)", [idsToDelete]); - res.json({ success: true, message: `\u6210\u529F\u5220\u9664 ${uniqueFiles.length} \u4E2A\u6587\u4EF6` }); - } catch (error) { - console.error("\u6279\u91CF\u5220\u9664\u5931\u8D25:", error); - res.status(500).json({ error: "\u6279\u91CF\u5220\u9664\u5931\u8D25" }); - } -}); function formatFileSize(bytes) { if (bytes === 0) return "0 B"; const k = 1024; @@ -7725,11 +8715,10 @@ router2.patch("/:id([0-9a-fA-F-]{36})/rename", async (req, res) => { if (/[\/\\:*?"<>|]/.test(trimmedName)) { return res.status(400).json({ error: "\u6587\u4EF6\u540D\u5305\u542B\u975E\u6CD5\u5B57\u7B26" }); } - 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - const file = result.rows[0]; const getExt = (n) => { const dotIndex = n.lastIndexOf("."); return dotIndex > 0 ? n.slice(dotIndex).toLowerCase() : ""; @@ -7739,43 +8728,13 @@ router2.patch("/:id([0-9a-fA-F-]{36})/rename", async (req, res) => { if (oldExt !== newExt) { return res.status(400).json({ error: "\u4E0D\u5141\u8BB8\u4FEE\u6539\u6587\u4EF6\u540E\u7F00" }); } - 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) { console.error("\u91CD\u547D\u540D\u6587\u4EF6\u5931\u8D25:", error); res.status(500).json({ error: "\u91CD\u547D\u540D\u6587\u4EF6\u5931\u8D25" }); } }); -router2.patch("/rename-folder", async (req, res) => { - try { - const { oldName, newName } = req.body; - if (!oldName || !newName || typeof oldName !== "string" || typeof newName !== "string") { - return res.status(400).json({ error: "\u53C2\u6570\u9519\u8BEF" }); - } - const trimmedNew = newName.trim(); - if (trimmedNew.length === 0) { - return res.status(400).json({ error: "\u6587\u4EF6\u5939\u540D\u4E0D\u80FD\u4E3A\u7A7A" }); - } - if (/[\/\\:*?"<>|]/.test(trimmedNew)) { - return res.status(400).json({ error: "\u6587\u4EF6\u5939\u540D\u5305\u542B\u975E\u6CD5\u5B57\u7B26" }); - } - 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: "\u6587\u4EF6\u5939\u4E0D\u5B58\u5728" }); - } - 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: "\u8BE5\u6587\u4EF6\u5939\u540D\u5DF2\u5B58\u5728" }); - } - } - await query("UPDATE files SET folder = $1 WHERE folder = $2", [trimmedNew, oldName]); - res.json({ success: true, name: trimmedNew }); - } catch (error) { - console.error("\u91CD\u547D\u540D\u6587\u4EF6\u5939\u5931\u8D25:", error); - res.status(500).json({ error: "\u91CD\u547D\u540D\u6587\u4EF6\u5939\u5931\u8D25" }); - } -}); router2.patch("/:id([0-9a-fA-F-]{36})/move", async (req, res) => { try { const { id } = req.params; @@ -7787,51 +8746,25 @@ router2.patch("/:id([0-9a-fA-F-]{36})/move", async (req, res) => { if (trimmedFolder && /[\/\\:*?"<>|]/.test(trimmedFolder)) { return res.status(400).json({ error: "\u6587\u4EF6\u5939\u540D\u5305\u542B\u975E\u6CD5\u5B57\u7B26" }); } - 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - 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) { console.error("\u79FB\u52A8\u6587\u4EF6\u5931\u8D25:", error); res.status(500).json({ error: "\u79FB\u52A8\u6587\u4EF6\u5931\u8D25" }); } }); -router2.patch("/move-folder", async (req, res) => { - try { - const { oldName, newName } = req.body; - if (!oldName || typeof oldName !== "string") { - return res.status(400).json({ error: "\u539F\u6587\u4EF6\u5939\u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A" }); - } - if (newName !== null && typeof newName !== "string") { - return res.status(400).json({ error: "\u76EE\u6807\u6587\u4EF6\u5939\u540D\u79F0\u683C\u5F0F\u9519\u8BEF" }); - } - const trimmedOld = oldName.trim(); - const trimmedNew = newName ? newName.trim() : null; - if (trimmedNew && /[\/\\:*?"<>|]/.test(trimmedNew)) { - return res.status(400).json({ error: "\u76EE\u6807\u6587\u4EF6\u5939\u540D\u5305\u542B\u975E\u6CD5\u5B57\u7B26" }); - } - 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: "\u539F\u6587\u4EF6\u5939\u4E0D\u5B58\u5728" }); - } - 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("\u79FB\u52A8\u6587\u4EF6\u5939\u5931\u8D25:", error); - res.status(500).json({ error: "\u79FB\u52A8\u6587\u4EF6\u5939\u5931\u8D25" }); - } -}); router2.post("/:id([0-9a-fA-F-]{36})/share", async (req, res) => { try { const { id } = req.params; const { password, expiration } = req.body; - 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - const file = result.rows[0]; const supportedSources = ["onedrive", "google_drive"]; if (!supportedSources.includes(file.source)) { return res.status(400).json({ error: "\u5F53\u524D\u5B58\u50A8\u6E90\u6682\u4E0D\u652F\u6301\u6587\u4EF6\u5206\u4EAB" }); @@ -7884,13 +8817,13 @@ router2.get("/favorites", async (req, res) => { router2.post("/:id([0-9a-fA-F-]{36})/favorite", async (req, res) => { 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: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - 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) { console.error("\u5207\u6362\u6536\u85CF\u72B6\u6001\u5931\u8D25:", error); @@ -7903,12 +8836,12 @@ var files_default = router2; init_db(); init_localPath(); import { Router as Router3 } from "express"; -import path15 from "path"; +import path17 from "path"; var router3 = Router3(); -var UPLOAD_DIR5 = path15.resolve(process.env.UPLOAD_DIR || "./data/uploads"); -var THUMBNAIL_DIR5 = path15.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); -var CLOUD_SOURCES = /* @__PURE__ */ new Set(["onedrive", "aliyun_oss", "s3", "webdav", "google_drive"]); -async function getCurrentStorageScope() { +var UPLOAD_DIR6 = path17.resolve(process.env.UPLOAD_DIR || "./data/uploads"); +var THUMBNAIL_DIR6 = path17.resolve(process.env.THUMBNAIL_DIR || "./data/thumbnails"); +var CLOUD_SOURCES2 = /* @__PURE__ */ new Set(["onedrive", "aliyun_oss", "s3", "webdav", "google_drive"]); +async function getCurrentStorageScope2() { const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); const provider = storageManager2.getProvider(); if (provider.name === "local") { @@ -7916,21 +8849,21 @@ async function getCurrentStorageScope() { } return { clause: "storage_account_id = $1", params: [storageManager2.getActiveAccountId()] }; } -function nextParam(scope, offset) { +function nextParam2(scope, offset) { return `$${scope.params.length + offset}`; } -async function removePhysicalFile(file) { - if (CLOUD_SOURCES.has(file.source)) { +async function removePhysicalFile2(file) { + if (CLOUD_SOURCES2.has(file.source)) { const { storageManager: storageManager2 } = await Promise.resolve().then(() => (init_storage(), storage_exports)); const provider = storageManager2.getProvider(`${file.source}:${file.storage_account_id}`); await provider.deleteFile(file.path); } else { - const filePath = file.path || path15.join(UPLOAD_DIR5, file.stored_name); - await safeUnlink(filePath, UPLOAD_DIR5); + const filePath = file.path || path17.join(UPLOAD_DIR6, file.stored_name); + await safeUnlink(filePath, UPLOAD_DIR6); } if (file.thumbnail_path) { - const thumbPath = path15.join(THUMBNAIL_DIR5, path15.basename(file.thumbnail_path)); - await safeUnlink(thumbPath, THUMBNAIL_DIR5); + const thumbPath = path17.join(THUMBNAIL_DIR6, path17.basename(file.thumbnail_path)); + await safeUnlink(thumbPath, THUMBNAIL_DIR6); } } router3.post("/batch-delete", async (req, res) => { @@ -7942,18 +8875,18 @@ router3.post("/batch-delete", async (req, res) => { if (fileIds.length === 0 && folderNames.length === 0) { return res.status(400).json({ error: "\u8BF7\u63D0\u4F9B\u8981\u5220\u9664\u7684\u6587\u4EF6\u6216\u6587\u4EF6\u5939" }); } - const scope = await getCurrentStorageScope(); + const scope = await getCurrentStorageScope2(); let filesToDelete = []; if (fileIds.length > 0) { const result = await query( - `SELECT * FROM files WHERE ${scope.clause} AND id = ANY(${nextParam(scope, 1)})`, + `SELECT * FROM files WHERE ${scope.clause} AND id = ANY(${nextParam2(scope, 1)})`, [...scope.params, fileIds] ); filesToDelete = [...filesToDelete, ...result.rows]; } if (folderNames.length > 0) { const result = await query( - `SELECT * FROM files WHERE ${scope.clause} AND folder = ANY(${nextParam(scope, 1)})`, + `SELECT * FROM files WHERE ${scope.clause} AND folder = ANY(${nextParam2(scope, 1)})`, [...scope.params, folderNames] ); filesToDelete = [...filesToDelete, ...result.rows]; @@ -7964,7 +8897,7 @@ router3.post("/batch-delete", async (req, res) => { } await Promise.all(uniqueFiles.map(async (file) => { try { - await removePhysicalFile(file); + await removePhysicalFile2(file); } catch (err) { console.error(`\u5220\u9664\u7269\u7406\u6587\u4EF6\u5931\u8D25 (ID: ${file.id}):`, err); } @@ -7990,9 +8923,9 @@ router3.patch("/rename-folder", async (req, res) => { if (/[\/\\:*?"<>|]/.test(trimmedNew)) { return res.status(400).json({ error: "\u6587\u4EF6\u5939\u540D\u5305\u542B\u975E\u6CD5\u5B57\u7B26" }); } - const scope = await getCurrentStorageScope(); + const scope = await getCurrentStorageScope2(); const checkResult = await query( - `SELECT COUNT(*) as cnt FROM files WHERE ${scope.clause} AND folder = ${nextParam(scope, 1)}`, + `SELECT COUNT(*) as cnt FROM files WHERE ${scope.clause} AND folder = ${nextParam2(scope, 1)}`, [...scope.params, oldName] ); if (parseInt(checkResult.rows[0].cnt) === 0) { @@ -8000,7 +8933,7 @@ router3.patch("/rename-folder", async (req, res) => { } if (trimmedNew !== oldName) { const existResult = await query( - `SELECT COUNT(*) as cnt FROM files WHERE ${scope.clause} AND folder = ${nextParam(scope, 1)}`, + `SELECT COUNT(*) as cnt FROM files WHERE ${scope.clause} AND folder = ${nextParam2(scope, 1)}`, [...scope.params, trimmedNew] ); if (parseInt(existResult.rows[0].cnt) > 0) { @@ -8008,7 +8941,7 @@ router3.patch("/rename-folder", async (req, res) => { } } await query( - `UPDATE files SET folder = ${nextParam(scope, 1)} WHERE ${scope.clause} AND folder = ${nextParam(scope, 2)}`, + `UPDATE files SET folder = ${nextParam2(scope, 1)} WHERE ${scope.clause} AND folder = ${nextParam2(scope, 2)}`, [...scope.params, trimmedNew, oldName] ); res.json({ success: true, name: trimmedNew }); @@ -8031,16 +8964,16 @@ router3.patch("/move-folder", async (req, res) => { if (trimmedNew && /[\/\\:*?"<>|]/.test(trimmedNew)) { return res.status(400).json({ error: "\u76EE\u6807\u6587\u4EF6\u5939\u540D\u5305\u542B\u975E\u6CD5\u5B57\u7B26" }); } - const scope = await getCurrentStorageScope(); + const scope = await getCurrentStorageScope2(); const checkResult = await query( - `SELECT COUNT(*) as cnt FROM files WHERE ${scope.clause} AND folder = ${nextParam(scope, 1)}`, + `SELECT COUNT(*) as cnt FROM files WHERE ${scope.clause} AND folder = ${nextParam2(scope, 1)}`, [...scope.params, trimmedOld] ); if (parseInt(checkResult.rows[0].cnt) === 0) { return res.status(404).json({ error: "\u539F\u6587\u4EF6\u5939\u4E0D\u5B58\u5728" }); } await query( - `UPDATE files SET folder = ${nextParam(scope, 1)}, updated_at = NOW() WHERE ${scope.clause} AND folder = ${nextParam(scope, 2)}`, + `UPDATE files SET folder = ${nextParam2(scope, 1)}, updated_at = NOW() WHERE ${scope.clause} AND folder = ${nextParam2(scope, 2)}`, [...scope.params, trimmedNew, trimmedOld] ); res.json({ success: true, folder: trimmedNew }); @@ -8056,8 +8989,8 @@ init_db(); import { Router as Router4 } from "express"; import multer from "multer"; import { v4 as uuidv4 } from "uuid"; -import path16 from "path"; -import fs12 from "fs"; +import path18 from "path"; +import fs13 from "fs"; // src/middleware/apiKey.ts init_db(); @@ -8142,16 +9075,16 @@ function decodeFilename(filename) { } return filename; } -var TEMP_DIR = path16.join(process.cwd(), "data", "temp"); -if (!fs12.existsSync(TEMP_DIR)) { - fs12.mkdirSync(TEMP_DIR, { recursive: true }); +var TEMP_DIR = path18.join(process.cwd(), "data", "temp"); +if (!fs13.existsSync(TEMP_DIR)) { + fs13.mkdirSync(TEMP_DIR, { recursive: true }); } var storage = multer.diskStorage({ destination: (_req, _file, cb) => { cb(null, TEMP_DIR); }, filename: (_req, file, cb) => { - const ext = path16.extname(file.originalname); + const ext = path18.extname(file.originalname); const storedName = `${uuidv4()}${ext}`; cb(null, storedName); } @@ -8172,7 +9105,7 @@ var handleUpload = async (req, res, source = "web") => { const originalName = decodeFilename(file.originalname); const mimeType = file.mimetype; const size = file.size; - const tempPath = path16.resolve(file.path); + const tempPath = path18.resolve(file.path); const activeAccountId = storageManager.getActiveAccountId(); const storageRules = await getStoragePathRules(); const storageFolder = buildStorageFolderWithRules({ source, folder: folder || null, mimeType, fileName: originalName }, storageRules); @@ -8189,7 +9122,7 @@ var handleUpload = async (req, res, source = "web") => { if (duplicateMode === "skip") { const duplicate = await findDuplicateFile(originalName, storageFolder, size, activeAccountId); if (duplicate) { - if (fs12.existsSync(tempPath)) fs12.unlinkSync(tempPath); + if (fs13.existsSync(tempPath)) fs13.unlinkSync(tempPath); return res.json({ success: true, skipped: true, @@ -8204,11 +9137,11 @@ var handleUpload = async (req, res, source = "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) { - thumbnailPath = path16.basename(thumbResult); + thumbnailPath = path18.basename(thumbResult); console.log(`[Upload] \u2728 Thumbnail generated: ${thumbnailPath}`); const dims = await getImageDimensions(tempPath, mimeType); width = dims.width; @@ -8224,12 +9157,12 @@ var handleUpload = async (req, res, source = "web") => { try { storedPath = await provider.saveFile(tempPath, storedName, mimeType, storageFolder); } catch (err) { - if (fs12.existsSync(tempPath)) fs12.unlinkSync(tempPath); + if (fs13.existsSync(tempPath)) fs13.unlinkSync(tempPath); throw err; } - if (fs12.existsSync(tempPath)) { + if (fs13.existsSync(tempPath)) { try { - fs12.unlinkSync(tempPath); + fs13.unlinkSync(tempPath); } catch (e) { console.warn("Failed to clean up temp file:", e); } @@ -8262,7 +9195,7 @@ var handleUpload = async (req, res, source = "web") => { }); } catch (error) { console.error("\u4E0A\u4F20\u5904\u7406\u5931\u8D25:", error); - if (fs12.existsSync(tempPath)) fs12.unlinkSync(tempPath); + if (fs13.existsSync(tempPath)) fs13.unlinkSync(tempPath); res.status(500).json({ error: "\u6587\u4EF6\u4E0A\u4F20\u5931\u8D25" }); } }; @@ -8279,13 +9212,13 @@ init_db(); import { Router as Router5 } from "express"; import checkDiskSpaceModule2 from "check-disk-space"; import os3 from "os"; -import path17 from "path"; -import fs13 from "fs"; +import path19 from "path"; +import fs14 from "fs"; import axios3 from "axios"; import crypto9 from "crypto"; var checkDiskSpace2 = checkDiskSpaceModule2.default || checkDiskSpaceModule2; var router5 = Router5(); -var UPLOAD_DIR6 = process.env.UPLOAD_DIR || "./data/uploads"; +var UPLOAD_DIR7 = process.env.UPLOAD_DIR || "./data/uploads"; function getOneDriveRedirectUri(req) { const apiBase = process.env.VITE_API_URL; if (apiBase) { @@ -8306,14 +9239,16 @@ function getGoogleDriveRedirectUri(req) { } router5.get("/stats", requireAuth, async (_req, res) => { try { - const diskPath = os3.platform() === "win32" ? "C:" : path17.resolve(UPLOAD_DIR6); + const diskPath = os3.platform() === "win32" ? "C:" : path19.resolve(UPLOAD_DIR7); const diskSpace = await checkDiskSpace2(diskPath); + 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]; res.json({ server: { @@ -8339,15 +9274,17 @@ router5.get("/stats", requireAuth, async (_req, res) => { }); router5.get("/stats/types", requireAuth, async (_req, res) => { 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, count: parseInt(row.count), @@ -8375,7 +9312,7 @@ router5.get("/config", requireAuth, async (req, res) => { const accounts = await storageManager2.getAccounts(); const telegramUserDownloadEnabled = await getSetting("telegram_user_download_enabled", "false"); const telegramUserSessionFilePath = getTelegramUserSessionFilePath(); - const telegramUserSessionReady = fs13.existsSync(telegramUserSessionFilePath) && isTelegramUserClientReady(); + const telegramUserSessionReady = fs14.existsSync(telegramUserSessionFilePath) && isTelegramUserClientReady(); const redirectUri = getOneDriveRedirectUri(req); res.json({ provider: provider.name, @@ -8434,7 +9371,7 @@ router5.get("/onedrive/callback", async (req, res) => { try { const { code, state, error, error_description } = req.query; if (error) { - return res.send(`\u6388\u6743\u5931\u8D25: ${error_description || error}`); + return res.type("text/plain").send(`\u6388\u6743\u5931\u8D25: ${String(error_description || error)}`); } if (!code) { return res.send("\u7F3A\u5C11\u6388\u6743\u7801 (code)"); @@ -8473,7 +9410,7 @@ router5.get("/onedrive/callback", async (req, res) => { if (errorCode === 7000215 || /invalid client secret|AADSTS7000215/i.test(errorDescription)) { return res.status(400).send("\u6388\u6743\u5931\u8D25\uFF1AMicrosoft \u8FD4\u56DE AADSTS7000215\uFF0CClient Secret \u65E0\u6548\u3002\u8BF7\u5728 Azure/Entra\u300C\u8BC1\u4E66\u548C\u5BC6\u7801\u300D\u4E2D\u65B0\u5EFA\u5BA2\u6237\u7AEF\u5BC6\u7801\uFF0C\u5E76\u590D\u5236\u201C\u503C Value\u201D\uFF08\u4E0D\u662F\u201C\u673A\u5BC6 ID/Secret ID\u201D\uFF09\u540E\u91CD\u65B0\u6388\u6743\u3002"); } - return res.status(err.response?.status || 400).send(`\u6388\u6743\u5931\u8D25\uFF1A${errorDescription}`); + return res.status(err.response?.status || 400).type("text/plain").send(`\u6388\u6743\u5931\u8D25\uFF1A${String(errorDescription)}`); } let accountName = "OneDrive Account"; try { @@ -8548,7 +9485,7 @@ router5.get("/google-drive/callback", async (req, res) => { try { const { code, state, error } = req.query; if (error) { - return res.send(`\u6388\u6743\u5931\u8D25: ${error}`); + return res.type("text/plain").send(`\u6388\u6743\u5931\u8D25: ${String(error)}`); } if (!code) { return res.send("\u7F3A\u5C11\u6388\u6743\u7801 (code)"); @@ -8582,7 +9519,8 @@ router5.get("/google-drive/callback", async (req, res) => {

\u{1F389} \u6388\u6743\u6210\u529F\uFF01

-

Google Drive \u5DF2\u6210\u529F\u8FDE\u63A5\u5E76\u542F\u7528\u3002

+

Google Drive \u5DF2\u6210\u529F\u8FDE\u63A5\u5E76\u542F\u7528\u3002

+

\u7A97\u53E3\u5C06\u81EA\u52A8\u5173\u95ED\u3002\u5982\u679C\u672A\u5173\u95ED\uFF0C\u8BF7\u624B\u52A8\u5173\u95ED\uFF0C\u4E3B\u9875\u9762\u4F1A\u81EA\u52A8\u5237\u65B0\u8D26\u6237\u5217\u8868\u3002