From cc19dc64350aab313596b81570db1c70b0fafaea Mon Sep 17 00:00:00 2001 From: O96u Date: Fri, 9 Oct 2026 15:39:24 +0800 Subject: [PATCH] release: v1.4.3 Add a Telegram channel or group storage backend, with proxied image URLs and an optional API proxy. --- CHANGELOG.md | 13 + README.en.md | 4 +- README.md | 4 +- app/components/storage/StorageBackendCard.vue | 18 +- app/components/storage/StorageBackendForm.vue | 282 ++++++---- .../storage/StorageSupportedBackends.vue | 7 + app/pages/storage.vue | 37 +- app/types/image.ts | 2 +- app/types/storage.ts | 5 +- docs-site/en/guide/changelog.md | 6 + docs-site/en/guide/storage.md | 25 +- docs-site/en/index.md | 2 +- docs-site/guide/changelog.md | 6 + docs-site/guide/storage.md | 25 +- docs-site/index.md | 2 +- i18n/locales/en.json | 13 + i18n/locales/zh-CN.json | 13 + package-lock.json | 16 +- package.json | 5 +- server/api/admin/storage/[id].patch.ts | 36 +- server/api/admin/storage/index.post.ts | 53 ++ server/api/logs/index.get.ts | 6 +- server/utils/backup/full-export.ts | 7 +- server/utils/process-image-upload.ts | 14 +- server/utils/storage-backends.ts | 164 +++++- server/utils/storage-capacity.ts | 4 +- server/utils/storage/index.ts | 7 + server/utils/storage/storage-object-refs.ts | 44 ++ server/utils/storage/telegram.test.ts | 297 ++++++++++ server/utils/storage/telegram.ts | 510 ++++++++++++++++++ server/utils/storage/types.ts | 19 +- server/utils/upload-policy.ts | 24 + 32 files changed, 1527 insertions(+), 143 deletions(-) create mode 100644 server/utils/storage/storage-object-refs.ts create mode 100644 server/utils/storage/telegram.test.ts create mode 100644 server/utils/storage/telegram.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9fce6..fb6adff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ## [Unreleased] +## [1.4.3] - 2026-10-09 + +### 新增 + +- **Telegram 存储后端**:用 Bot 把图片存入频道或群组(含私有);直链仍由 PicHost 代理,不暴露 Bot Token +- 可选 HTTP/HTTPS API 代理,供服务器访问 Telegram +- 群组升级为超级群组时自动改用新的 Chat ID + +### 说明 + +- 单文件约 20MB,同一聊天约 20 条/分钟 +- 图库删除会尝试删除对应 Telegram 消息;备份包不含 Bot Token + ## [1.4.2] - 2026-09-23 ### 修复 diff --git a/README.en.md b/README.en.md index 6dcdc66..922d2f2 100644 --- a/README.en.md +++ b/README.en.md @@ -55,7 +55,7 @@ - **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection - **Multi-user** — login with slider, Turnstile, or Cap verification; optional registration; users see only their images -- **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs +- **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS) + Telegram channel/group; hybrid `proxy` / `public` URLs - **Gallery** — browse, search, filter by storage/source/tags, grid/list views, batch delete and batch tagging; stats overview and source breakdown; image detail modal with link formats - **Tags** — per-user colored tags; tag on upload (single or batch); manage in Settings (create, recolor, merge, delete); API supports `tagIds` and batch tagging - **Backup & migration** — full-site export/restore (`.phost.tar.gz`), cross-backend sync; restore from backup on uninitialized `/setup` @@ -70,7 +70,7 @@ | Backend | [Nitro](https://nitro.build) (`node-server`) · REST API | | Data | SQLite · local `data/pichost.db` | | Images | [sharp](https://sharp.pixelplumbing.com) (server-side WebP) | -| Storage | Local disk · S3-compatible ([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS, etc.) | +| Storage | Local disk · S3-compatible ([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS, etc.) · Telegram channel/group | | i18n | [@nuxtjs/i18n](https://i18n.nuxtjs.org) (zh-CN / English) | | Quality & docs | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) docs site | | Deploy | Docker · Node.js 22 | diff --git a/README.md b/README.md index 8bdecb6..995403b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ - **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链 - **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片 -- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public` +- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS)+ Telegram 频道/群组;混合直链 `proxy` / `public` - **图库**:浏览、搜索、按存储/来源/标签筛选、网格/列表切换、批量删除与批量打标;统计概览与来源分布;点击图片查看详情与多格式链接 - **标签**:每用户独立标签、彩色标记;上传后单张/批量打标;设置页管理(新建、改色、合并、删除);API 支持 `tagIds` 与批量打标 - **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复 @@ -70,7 +70,7 @@ | 后端 | [Nitro](https://nitro.build)(`node-server`)· REST API | | 数据 | SQLite · 本地 `data/pichost.db` | | 图片处理 | [sharp](https://sharp.pixelplumbing.com)(服务端 WebP 压缩) | -| 存储 | 本地磁盘 · S3 兼容([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS 等) | +| 存储 | 本地磁盘 · S3 兼容([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS 等)· Telegram 频道/群组 | | 国际化 | [@nuxtjs/i18n](https://i18n.nuxtjs.org)(简体中文 / English) | | 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 | | 部署 | Docker · Node.js 22 | diff --git a/app/components/storage/StorageBackendCard.vue b/app/components/storage/StorageBackendCard.vue index 57c1a2f..e618ba9 100644 --- a/app/components/storage/StorageBackendCard.vue +++ b/app/components/storage/StorageBackendCard.vue @@ -21,13 +21,17 @@ const emit = defineEmits<{ const { t } = useI18n() -const typeIcon = computed(() => - props.backend.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud' -) +const typeIcon = computed(() => { + if (props.backend.type === 'local') return 'i-lucide-hard-drive' + if (props.backend.type === 'telegram') return 'i-lucide-send' + return 'i-lucide-cloud' +}) -const typeLabel = computed(() => - props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud') -) +const typeLabel = computed(() => { + if (props.backend.type === 'local') return t('storage.backendLocal') + if (props.backend.type === 'telegram') return t('storage.backendTelegram') + return t('storage.backendCloud') +}) const actionsDisabled = computed(() => props.envOverride || props.busy) @@ -38,7 +42,7 @@ function canSetDefault() { } function canEdit() { - return props.backend.type === 's3' + return props.backend.type === 's3' || props.backend.type === 'telegram' } function canToggle() { diff --git a/app/components/storage/StorageBackendForm.vue b/app/components/storage/StorageBackendForm.vue index 5fdc1e5..59f1680 100644 --- a/app/components/storage/StorageBackendForm.vue +++ b/app/components/storage/StorageBackendForm.vue @@ -2,9 +2,10 @@ import type { QuotaUnit } from '~/composables/useFileSize' import type { StorageBackendItem } from '~/types/storage' -type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom' +type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom' | 'telegram' export interface StorageFormPayload { + backendType: 's3' | 'telegram' name: string provider: StorageProvider config: { @@ -13,10 +14,13 @@ export interface StorageFormPayload { bucket: string prefix?: string forcePathStyle: boolean + chatId?: string + apiProxyUrl?: string } secrets: { accessKeyId: string secretAccessKey: string + botToken: string } servingMode: 'proxy' | 'public' publicUrl: string @@ -47,11 +51,18 @@ const prefix = ref('') const forcePathStyle = ref(false) const accessKeyId = ref('') const secretAccessKey = ref('') +const chatId = ref('') +const botToken = ref('') +const apiProxyUrl = ref('') const servingMode = ref<'proxy' | 'public'>('proxy') const publicUrl = ref('') const quotaValue = ref('') const quotaUnit = ref('GB') +const isTelegram = computed(() => + provider.value === 'telegram' || props.backend?.type === 'telegram' +) + const quotaUnitOptions = computed(() => QUOTA_UNITS.map(unit => ({ label: unit, value: unit })) ) @@ -61,7 +72,8 @@ const providerOptions = computed(() => [ { label: t('storage.providerCos'), value: 'cos' as const }, { label: t('storage.providerOss'), value: 'oss' as const }, { label: t('storage.providerAws'), value: 'aws' as const }, - { label: t('storage.providerCustom'), value: 'custom' as const } + { label: t('storage.providerCustom'), value: 'custom' as const }, + { label: t('storage.providerTelegram'), value: 'telegram' as const } ]) const servingModeOptions = computed(() => [ @@ -79,6 +91,10 @@ function applyProviderTemplate(value: StorageProvider) { if (props.createMode) { name.value = providerLabel(value) } + if (value === 'telegram') { + servingMode.value = 'proxy' + return + } switch (value) { case 'r2': endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com' @@ -114,21 +130,44 @@ function detectProvider(config: StorageBackendItem['config']): StorageProvider { return 'custom' } +function resetCreateDefaults() { + name.value = '' + provider.value = 'r2' + applyProviderTemplate('r2') + bucket.value = '' + prefix.value = '' + accessKeyId.value = '' + secretAccessKey.value = '' + chatId.value = '' + botToken.value = '' + apiProxyUrl.value = '' + servingMode.value = 'proxy' + publicUrl.value = '' + quotaValue.value = '' + quotaUnit.value = 'GB' +} + function loadBackend(backend: StorageBackendItem | null | undefined) { - if (!backend || backend.type !== 's3') { - if (props.createMode) { - name.value = '' - provider.value = 'r2' - applyProviderTemplate('r2') - bucket.value = '' - prefix.value = '' - accessKeyId.value = '' - secretAccessKey.value = '' - servingMode.value = 'proxy' - publicUrl.value = '' - quotaValue.value = '' - quotaUnit.value = 'GB' - } + if (!backend) { + if (props.createMode) resetCreateDefaults() + return + } + + if (backend.type === 'telegram') { + name.value = backend.name + provider.value = 'telegram' + chatId.value = backend.config.chatId ?? '' + apiProxyUrl.value = backend.config.apiProxyUrl ?? '' + botToken.value = '' + const quota = bytesToQuotaInput(backend.quotaBytes) + quotaValue.value = quota.value + quotaUnit.value = quota.unit + servingMode.value = 'proxy' + return + } + + if (backend.type !== 's3') { + if (props.createMode) resetCreateDefaults() return } @@ -157,7 +196,9 @@ watch(provider, (value) => { }) function handleSubmit() { + const backendType = isTelegram.value ? 'telegram' : 's3' emit('submit', { + backendType, name: name.value.trim() || providerLabel(provider.value), provider: provider.value, config: { @@ -165,13 +206,16 @@ function handleSubmit() { region: region.value.trim() || 'auto', bucket: bucket.value.trim(), prefix: prefix.value.trim() || undefined, - forcePathStyle: forcePathStyle.value + forcePathStyle: forcePathStyle.value, + chatId: chatId.value.trim() || undefined, + apiProxyUrl: apiProxyUrl.value.trim() || undefined }, secrets: { accessKeyId: accessKeyId.value.trim(), - secretAccessKey: secretAccessKey.value.trim() + secretAccessKey: secretAccessKey.value.trim(), + botToken: botToken.value.trim() }, - servingMode: servingMode.value, + servingMode: isTelegram.value ? 'proxy' : servingMode.value, publicUrl: publicUrl.value.trim(), quotaValue: String(quotaValue.value ?? '').trim(), quotaUnit: quotaUnit.value @@ -190,6 +234,7 @@ function handleSubmit() { v-model="provider" :items="providerOptions" class="w-full" + :disabled="!createMode && backend?.type === 'telegram'" /> @@ -201,105 +246,158 @@ function handleSubmit() { /> -
- - -
- -
-
- - -
+ -
- - -
+

- {{ t('storage.quotaHint') }} + {{ isTelegram ? t('storage.telegramQuotaHint') : t('storage.quotaHint') }}

diff --git a/app/components/storage/StorageSupportedBackends.vue b/app/components/storage/StorageSupportedBackends.vue index cc41be6..55d4020 100644 --- a/app/components/storage/StorageSupportedBackends.vue +++ b/app/components/storage/StorageSupportedBackends.vue @@ -22,6 +22,13 @@ const items = computed(() => [ boxClass: 'border-blue-500/20 bg-blue-500/5', title: t('storage.supportedS3Title'), desc: t('storage.supportedS3Desc') + }, + { + icon: 'i-lucide-send', + iconClass: 'text-sky-500', + boxClass: 'border-sky-500/20 bg-sky-500/5', + title: t('storage.backendTelegram'), + desc: t('storage.supportedTelegramDesc') } ]) diff --git a/app/pages/storage.vue b/app/pages/storage.vue index c7a8797..29daf49 100644 --- a/app/pages/storage.vue +++ b/app/pages/storage.vue @@ -93,16 +93,42 @@ const { quotaToBytes } = useFileSize() function buildPatchBody(payload: StorageFormPayload) { const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit) + if (payload.backendType === 'telegram') { + const body: Record = { + type: 'telegram', + name: payload.name, + quotaBytes, + config: { + chatId: payload.config.chatId, + apiProxyUrl: payload.config.apiProxyUrl + } + } + if (payload.secrets.botToken) { + body.secrets = { botToken: payload.secrets.botToken } + } + return body + } + const body: Record = { + type: 's3', name: payload.name, provider: payload.provider, servingMode: payload.servingMode, publicUrl: payload.publicUrl, quotaBytes, - config: payload.config + config: { + endpoint: payload.config.endpoint, + region: payload.config.region, + bucket: payload.config.bucket, + prefix: payload.config.prefix, + forcePathStyle: payload.config.forcePathStyle + } } if (payload.secrets.accessKeyId || payload.secrets.secretAccessKey) { - body.secrets = payload.secrets + body.secrets = { + accessKeyId: payload.secrets.accessKeyId, + secretAccessKey: payload.secrets.secretAccessKey + } } return body } @@ -128,7 +154,12 @@ async function onFormSubmit(payload: StorageFormPayload) { } if (formMode.value === 'create') { - if (!payload.secrets.accessKeyId || !payload.secrets.secretAccessKey) { + if (payload.backendType === 'telegram') { + if (!payload.secrets.botToken || !payload.config.chatId) { + toast.add({ title: t('storage.telegramSecretsRequired'), color: 'warning' }) + return + } + } else if (!payload.secrets.accessKeyId || !payload.secrets.secretAccessKey) { toast.add({ title: t('storage.secretsRequired'), color: 'warning' }) return } diff --git a/app/types/image.ts b/app/types/image.ts index a0b05da..d3dd315 100644 --- a/app/types/image.ts +++ b/app/types/image.ts @@ -40,7 +40,7 @@ export interface ImageItem { storage?: { id: string name: string - type: 'local' | 's3' + type: 'local' | 's3' | 'telegram' } /** 上传来源:网页或 API(来自活动日志) */ uploadSource?: 'web' | 'api' diff --git a/app/types/storage.ts b/app/types/storage.ts index 93d3077..ebb744c 100644 --- a/app/types/storage.ts +++ b/app/types/storage.ts @@ -11,17 +11,20 @@ export interface StorageCapacity { export interface StorageBackendItem { id: string name: string - type: 'local' | 's3' + type: 'local' | 's3' | 'telegram' config: { endpoint?: string region?: string bucket?: string prefix?: string forcePathStyle?: boolean + chatId?: string + apiProxyUrl?: string } secretsMasked: { accessKeyId?: string secretAccessKey?: string + botToken?: string } servingMode: ServingMode publicUrl: string diff --git a/docs-site/en/guide/changelog.md b/docs-site/en/guide/changelog.md index 38ba2ff..c84f408 100644 --- a/docs-site/en/guide/changelog.md +++ b/docs-site/en/guide/changelog.md @@ -4,6 +4,12 @@ Version history and notable changes. The full log is also in the repo root [CHAN --- +## [1.4.3] — 2026-10-09 + +### Added + +- **Telegram storage backend**: store images in a channel or group; public URLs stay on PicHost; optional API proxy + ## [1.4.2] — 2026-09-23 ### Fixed diff --git a/docs-site/en/guide/storage.md b/docs-site/en/guide/storage.md index 6a4c453..c9ba155 100644 --- a/docs-site/en/guide/storage.md +++ b/docs-site/en/guide/storage.md @@ -1,6 +1,6 @@ # Storage -PicHost supports **local disk** and **S3-compatible object storage** (Cloudflare R2, Tencent COS, Alibaba OSS, AWS S3, etc.). Admins manage backends at **Storage** (`/storage`); new uploads go to the **default** backend. The page shows a **storage overview** (total, used, remaining, usage rate, capacity chart) plus per-backend cards and supported backend types. +PicHost supports **local disk**, **S3-compatible object storage** (Cloudflare R2, Tencent COS, Alibaba OSS, AWS S3, etc.), and a **Telegram channel or group**. Admins manage backends at **Storage** (`/storage`); new uploads go to the **default** backend. The page shows a **storage overview** (total, used, remaining, usage rate, capacity chart) plus per-backend cards and supported backend types. ![Storage management](/screenshots/storage.png) @@ -23,11 +23,11 @@ All **keys** start with `images/`. The upload API does **not** accept a `folder` ## Adding a backend 1. Sign in as admin → **Storage** → **Add backend**. -2. Choose **local disk** or **S3-compatible** (R2 / COS / OSS presets). -3. Enter connection details (bucket, endpoint, keys). +2. Choose **local disk**, **S3-compatible** (R2 / COS / OSS presets), or **Telegram channel**. +3. Enter connection details (bucket, endpoint, keys, or Bot Token / Chat ID). 4. **Set as default** so new uploads use it. -New object-storage backends get an auto-generated **storage ID** prefix: `local` for disk; `r2-*` for R2; `cos-*` for COS; `oss-*` for OSS; `s3-*` for AWS S3 and custom S3-compatible endpoints (immutable after creation; the image index references this ID). +New backends get an auto-generated **storage ID** prefix: `local` for disk; `r2-*` for R2; `cos-*` for COS; `oss-*` for OSS; `s3-*` for AWS S3 and custom S3-compatible endpoints; `tg-*` for Telegram (immutable after creation; the image index references this ID). ### Cloudflare R2 @@ -40,6 +40,23 @@ New object-storage backends get an auto-generated **storage ID** prefix: `local` The **cloudflare** branch streamlines R2-only deploys; see branch notes on the [docs overview](./index.md). +### Telegram channel or group + +A bot posts each image into a channel or group (private chats work). Visitors still load PicHost URLs; the app never redirects to a Telegram file URL. + +1. Create a bot with [@BotFather](https://t.me/BotFather) and copy the token. +2. Make the bot an admin of the channel or group (post messages; also grant delete messages if gallery deletes should remove the Telegram post). +3. On the Storage page choose **Telegram channel**, then enter the Chat ID (supergroups and channels are usually `-100…`), the bot token, and a quota. +4. If the server cannot reach `api.telegram.org` directly, set an optional HTTP/HTTPS API proxy (for example `http://127.0.0.1:7890`). + +Limits and behavior: + +- Practical per-file cap is about **20MB** (the smaller of this and the site upload limit applies) +- About **20 messages per minute** per chat; faster batches fail with a retry hint +- Serving mode is always **proxy** +- Gallery delete tries to delete the Telegram message. If an upload reaches the chat but PicHost cannot store a file id, that message is removed when possible +- Full backups download files through the bot and are subject to the rate limit. Packages do not include the bot token; enter it again after restore. Restoring onto a default Telegram backend re-uploads images into the chat + ### Environment variables (optional) Some installs seed a default S3 backend via `STORAGE_BACKEND=s3`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`. **UI-configured backends live in SQLite** alongside env-based defaults. diff --git a/docs-site/en/index.md b/docs-site/en/index.md index 9d61d59..9005313 100644 --- a/docs-site/en/index.md +++ b/docs-site/en/index.md @@ -20,7 +20,7 @@ features: - title: Drag, click, paste details: Server-side WebP compression and Referer hotlink protection. - title: Multi-backend storage - details: Local disk plus S3-compatible backends (R2 / COS / OSS / AWS); proxy or public URLs. + details: Local disk, S3-compatible backends (R2 / COS / OSS / AWS), and Telegram; proxy or public URLs. - title: Users & RBAC details: Admin and regular users; global and per-user API tokens; gallery scoped by owner. - title: Zero-config Docker diff --git a/docs-site/guide/changelog.md b/docs-site/guide/changelog.md index 3d03eb6..eb713b3 100644 --- a/docs-site/guide/changelog.md +++ b/docs-site/guide/changelog.md @@ -4,6 +4,12 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C --- +## [1.4.3] — 2026-10-09 + +### 新增 + +- **Telegram 存储后端**:频道/群组存图,直链仍经 PicHost;可选 API 代理 + ## [1.4.2] — 2026-09-23 ### 修复 diff --git a/docs-site/guide/storage.md b/docs-site/guide/storage.md index 9d1382e..2848e5e 100644 --- a/docs-site/guide/storage.md +++ b/docs-site/guide/storage.md @@ -1,6 +1,6 @@ # 存储 -PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。页面顶部展示 **存储总览**(总量、已用、剩余、使用率与容量分布),下方为各后端卡片与支持的后端类型说明。 +PicHost 支持 **本地磁盘**、**S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)与 **Telegram 频道/群组**。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。页面顶部展示 **存储总览**(总量、已用、剩余、使用率与容量分布),下方为各后端卡片与支持的后端类型说明。 ![存储管理](/screenshots/storage.png) @@ -23,11 +23,11 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、 ## 添加存储后端 1. 以管理员登录,打开 **存储** → **添加存储**。 -2. 选择类型:**本地磁盘** 或 **S3 兼容**(含 R2 / COS / OSS 预设)。 -3. 填写连接信息(桶名、Endpoint、Access Key 等)。 +2. 选择类型:**本地磁盘**、**S3 兼容**(含 R2 / COS / OSS 预设)或 **Telegram 频道**。 +3. 填写连接信息(桶名、Endpoint、Access Key,或 Bot Token / Chat ID 等)。 4. 保存后可 **设默认**,新上传将写入该后端。 -新建对象存储后端的 **存储标识** 按类型自动生成前缀:本地为 `local`;R2 为 `r2-*`;COS 为 `cos-*`;OSS 为 `oss-*`;AWS S3 及自定义 S3 兼容为 `s3-*`(创建后不可修改,图片索引会引用该 ID)。 +新建后端的 **存储标识** 按类型自动生成前缀:本地为 `local`;R2 为 `r2-*`;COS 为 `cos-*`;OSS 为 `oss-*`;AWS S3 及自定义 S3 兼容为 `s3-*`;Telegram 为 `tg-*`(创建后不可修改,图片索引会引用该 ID)。 ### Cloudflare R2 要点 @@ -40,6 +40,23 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、 若仓库 **cloudflare** 分支专用于「仅 R2」部署,也可用环境变量预设,见 [文档首页](./index.md) 分支说明。 +### Telegram 频道 / 群组 + +用 Bot 把图片发进指定频道或群组(含私有频道),访客仍通过 PicHost 直链访问,不会跳到 Telegram 文件地址。 + +1. 在 [@BotFather](https://t.me/BotFather) 创建 Bot,取得 Token。 +2. 把 Bot 设为频道或群组管理员(需要发消息;若希望图库删除时同步删掉群消息,还需要删除消息权限)。 +3. 在存储页选择 **Telegram 频道**,填写 Chat ID(超级群组/频道多为 `-100…`)、Bot Token,以及套餐配额。 +4. 服务器若不能直连 `api.telegram.org`,可填写可选的 HTTP/HTTPS API 代理(如 `http://127.0.0.1:7890`)。 + +限制与行为: + +- 单文件实际上限约 **20MB**(超过站点上传限制时,以更小者为准) +- 同一频道大约 **20 条消息/分钟**;批量上传过快会失败并提示稍后重试 +- 直链固定为 **proxy**,不能改为公开 URL +- 图库删除会尝试删除对应的 Telegram 消息;上传失败且未记下文件编号时,会尽量撤回刚发出的消息 +- 整站备份会经 Bot 拉取文件,耗时并受上述频率限制;备份包不含 Bot Token,恢复后需重新填写。恢复到 Telegram 默认后端时,会把图片重新发进频道 + ### 环境变量(可选) 部分部署用环境变量初始化默认 S3 后端(如 `STORAGE_BACKEND=s3`、`S3_ENDPOINT`、`S3_BUCKET`、`S3_ACCESS_KEY`、`S3_SECRET_KEY`、`S3_REGION`)。**UI 中配置的后端信息存于 SQLite**,与环境变量并存时以管理界面为准管理多后端。 diff --git a/docs-site/index.md b/docs-site/index.md index 048e7a6..7d6e55b 100644 --- a/docs-site/index.md +++ b/docs-site/index.md @@ -20,7 +20,7 @@ features: - title: 拖拽 / 粘贴上传 details: 支持点击、拖拽与 Ctrl+V 粘贴;服务端 WebP 压缩与 Referer 防盗链。 - title: 多后端存储 - details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);proxy 或 public 直链模式。 + details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS)+ Telegram 频道/群组;proxy 或 public 直链。 - title: 多用户与权限 details: 管理员与普通用户 RBAC;全局 Token 与个人 Token;图库按归属隔离。 - title: Docker 零配置 diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 50a942c..419c875 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -455,6 +455,18 @@ "providerOss": "Alibaba OSS", "providerAws": "AWS S3", "providerCustom": "Custom", + "providerTelegram": "Telegram channel", + "backendTelegram": "Telegram", + "telegramChatId": "Chat ID", + "telegramChatIdPlaceholder": "-1001234567890", + "telegramChatIdHint": "Channel or group ID; the bot must be an admin. Private channels work.", + "telegramBotToken": "Bot Token", + "telegramApiProxy": "API proxy (optional)", + "telegramApiProxyPlaceholder": "http://127.0.0.1:7890", + "telegramApiProxyHint": "HTTP/HTTPS proxy for PicHost → Telegram API; leave empty for direct access.", + "telegramServingHint": "Image URLs are always served through PicHost, never redirected to Telegram.", + "telegramQuotaHint": "Telegram practical limit is ~20MB per file and ~20 messages/min per channel.", + "telegramSecretsRequired": "Bot Token and Chat ID are required", "endpoint": "Endpoint", "region": "Region", "bucket": "Bucket", @@ -516,6 +528,7 @@ "supportedR2Desc": "Highly available, low-cost object storage", "supportedS3Title": "S3 compatible", "supportedS3Desc": "Storage services compatible with the Amazon S3 API", + "supportedTelegramDesc": "Store images in a Telegram channel or group; good for light unlimited storage", "footerTip": "The default backend receives all new uploads", "statTotal": "Total", "statUsed": "Used", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 88fd1d9..401f296 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -455,6 +455,18 @@ "providerOss": "阿里云 OSS", "providerAws": "AWS S3", "providerCustom": "自定义", + "providerTelegram": "Telegram 频道", + "backendTelegram": "Telegram", + "telegramChatId": "Chat ID", + "telegramChatIdPlaceholder": "-1001234567890", + "telegramChatIdHint": "频道或群组 ID;Bot 须为管理员。私有频道可用。", + "telegramBotToken": "Bot Token", + "telegramApiProxy": "API 代理(可选)", + "telegramApiProxyPlaceholder": "http://127.0.0.1:7890", + "telegramApiProxyHint": "PicHost 访问 Telegram API 时使用的 HTTP/HTTPS 代理;留空为直连。", + "telegramServingHint": "图片直链固定经 PicHost 代理,不会跳转到 Telegram。", + "telegramQuotaHint": "Telegram 单文件实际可读上限约 20MB;频道约 20 条/分钟。", + "telegramSecretsRequired": "请填写 Bot Token 与 Chat ID", "endpoint": "Endpoint", "region": "Region", "bucket": "Bucket", @@ -516,6 +528,7 @@ "supportedR2Desc": "高可用、低成本的对象存储", "supportedS3Title": "S3 兼容", "supportedS3Desc": "兼容 Amazon S3 协议的存储服务", + "supportedTelegramDesc": "用 Telegram 频道/群组存图,适合轻量与无限容量场景", "footerTip": "默认存储后端将自动接收所有新上传的图片", "statTotal": "总量", "statUsed": "已用", diff --git a/package-lock.json b/package-lock.json index 0f2d4e1..235546b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pichost", - "version": "1.4.2", + "version": "1.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pichost", - "version": "1.4.2", + "version": "1.4.3", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { @@ -18,7 +18,8 @@ "nuxt": "^4.4.8", "sharp": "^0.35.3", "tailwindcss": "^4.3.1", - "tar": "^7.5.22" + "tar": "^7.5.22", + "undici": "^7.30.0" }, "devDependencies": { "@nuxt/eslint": "^1.16.0", @@ -19181,6 +19182,15 @@ "node": ">=18.12.0" } }, + "node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index a7a5aca..0b6ce0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pichost", - "version": "1.4.2", + "version": "1.4.3", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "homepage": "https://github.com/O96u/PicHost#readme", "repository": { @@ -53,7 +53,8 @@ "nuxt": "^4.4.8", "sharp": "^0.35.3", "tailwindcss": "^4.3.1", - "tar": "^7.5.22" + "tar": "^7.5.22", + "undici": "^7.30.0" }, "devDependencies": { "@nuxt/eslint": "^1.16.0", diff --git a/server/api/admin/storage/[id].patch.ts b/server/api/admin/storage/[id].patch.ts index a4375ed..72b424e 100644 --- a/server/api/admin/storage/[id].patch.ts +++ b/server/api/admin/storage/[id].patch.ts @@ -9,6 +9,11 @@ import { type StorageBackendPatch } from '../../../utils/storage-backends' import { parseS3Config, validateS3Config } from '../../../utils/storage/s3' +import { + parseTelegramConfig, + validateTelegramConfig, + validateApiProxyUrl +} from '../../../utils/storage/telegram' import { parseQuotaBytes } from '../../../utils/storage-capacity' import type { ServingMode } from '../../../utils/storage/types' @@ -20,10 +25,13 @@ interface StoragePatchBody { bucket?: string prefix?: string forcePathStyle?: boolean + chatId?: string + apiProxyUrl?: string } secrets?: { accessKeyId?: string secretAccessKey?: string + botToken?: string } servingMode?: ServingMode publicUrl?: string @@ -63,6 +71,26 @@ export default defineEventHandler(async (event) => { patch.name = body.name.trim() || row.name } + if (body.config !== undefined && row.type === 'telegram') { + const current = parseTelegramConfig(row.config_json) ?? { chatId: '' } + const apiProxyUrl = body.config.apiProxyUrl?.trim() ?? current.apiProxyUrl ?? '' + const next = { + chatId: body.config.chatId?.trim() ?? current.chatId, + apiProxyUrl: apiProxyUrl || undefined + } + const error = validateTelegramConfig(next) + if (error) { + createApiError(event, 'INVALID_REQUEST', error, 400) + } + if (apiProxyUrl) { + const proxyError = validateApiProxyUrl(apiProxyUrl) + if (proxyError) { + createApiError(event, 'INVALID_REQUEST', proxyError, 400) + } + } + patch.config = next + } + if (body.config !== undefined && row.type === 's3') { const current = parseS3Config(row.config_json) ?? { endpoint: '', @@ -87,7 +115,11 @@ export default defineEventHandler(async (event) => { patch.secrets = body.secrets } - if (body.servingMode !== undefined) { + if (body.secrets !== undefined && row.type === 'telegram') { + patch.secrets = body.secrets + } + + if (body.servingMode !== undefined && row.type !== 'telegram') { if (body.servingMode !== 'proxy' && body.servingMode !== 'public') { createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400) } @@ -99,7 +131,7 @@ export default defineEventHandler(async (event) => { } if (body.quotaBytes !== undefined) { - if (row.type === 's3') { + if (row.type === 's3' || row.type === 'telegram') { const quotaBytes = parseQuotaBytes(body.quotaBytes) if (!quotaBytes) { createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400) diff --git a/server/api/admin/storage/index.post.ts b/server/api/admin/storage/index.post.ts index 6dc7dfa..ec87dc3 100644 --- a/server/api/admin/storage/index.post.ts +++ b/server/api/admin/storage/index.post.ts @@ -2,13 +2,19 @@ import { requireAdminAuth } from '../../../utils/access' import { createApiError } from '../../../utils/api-error' import { insertStorageBackend, + insertTelegramBackend, isStorageEnvConfigured } from '../../../utils/storage-backends' import { validateS3Config } from '../../../utils/storage/s3' +import { + validateTelegramConfig, + validateApiProxyUrl +} from '../../../utils/storage/telegram' import { parseQuotaBytes } from '../../../utils/storage-capacity' import type { ServingMode } from '../../../utils/storage/types' interface StorageCreateBody { + type?: 's3' | 'telegram' name?: string provider?: string config?: { @@ -17,10 +23,13 @@ interface StorageCreateBody { bucket?: string prefix?: string forcePathStyle?: boolean + chatId?: string + apiProxyUrl?: string } secrets?: { accessKeyId?: string secretAccessKey?: string + botToken?: string } servingMode?: ServingMode publicUrl?: string @@ -40,6 +49,50 @@ export default defineEventHandler(async (event) => { (): StorageCreateBody => ({}) ) + const backendType = body.type === 'telegram' ? 'telegram' : 's3' + + if (backendType === 'telegram') { + const name = body.name?.trim() || 'Telegram 频道' + const chatId = body.config?.chatId?.trim() ?? '' + const apiProxyUrl = body.config?.apiProxyUrl?.trim() ?? '' + const botToken = body.secrets?.botToken?.trim() ?? '' + + if (!chatId || !botToken) { + createApiError(event, 'INVALID_REQUEST', 'Bot Token 与 Chat ID 不能为空', 400) + } + + const config = { + chatId, + apiProxyUrl: apiProxyUrl || undefined + } + const configError = validateTelegramConfig(config) + if (configError) { + createApiError(event, 'INVALID_REQUEST', configError, 400) + } + if (apiProxyUrl) { + const proxyError = validateApiProxyUrl(apiProxyUrl) + if (proxyError) { + createApiError(event, 'INVALID_REQUEST', proxyError, 400) + } + } + + const quotaBytes = parseQuotaBytes(body.quotaBytes) + if (!quotaBytes) { + createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400) + } + + const id = insertTelegramBackend({ + name, + config, + secrets: { botToken }, + quotaBytes, + enabled: body.enabled, + isDefault: body.isDefault + }) + + return { success: true, id } + } + const name = body.name?.trim() || 'S3 兼容存储' const config = { endpoint: body.config?.endpoint?.trim() ?? '', diff --git a/server/api/logs/index.get.ts b/server/api/logs/index.get.ts index 21233e0..7bc716d 100644 --- a/server/api/logs/index.get.ts +++ b/server/api/logs/index.get.ts @@ -106,7 +106,11 @@ export default defineEventHandler(async (event) => { ? { id: row.backend_id, name: row.backend_name ?? row.backend_id, - type: (row.backend_type === 'local' ? 'local' : 's3') as 'local' | 's3' + type: (row.backend_type === 'local' + ? 'local' + : row.backend_type === 'telegram' + ? 'telegram' + : 's3') as 'local' | 's3' | 'telegram' } : null, ipAddress: row.ip_address, diff --git a/server/utils/backup/full-export.ts b/server/utils/backup/full-export.ts index 4870003..a5ece98 100644 --- a/server/utils/backup/full-export.ts +++ b/server/utils/backup/full-export.ts @@ -7,6 +7,7 @@ import { listStorageBackendRows } from '../storage-backends' import { parseS3Config } from '../storage/s3' +import { parseTelegramConfig } from '../storage/telegram' import { createImageStream } from '../storage' import { copyDatabaseSnapshot } from './db-snapshot' import { markJobDone, markJobFailed, updateBackupJob } from './jobs' @@ -27,7 +28,11 @@ function buildStorageBackendsMeta(): StorageBackendMetaExport[] { id: row.id, name: row.name, type: row.type, - config: row.type === 's3' ? (parseS3Config(row.config_json) ?? {}) : {}, + config: row.type === 's3' + ? (parseS3Config(row.config_json) ?? {}) + : row.type === 'telegram' + ? (parseTelegramConfig(row.config_json) ?? {}) + : {}, servingMode: row.serving_mode, publicUrl: row.public_url, quotaBytes: row.quota_bytes, diff --git a/server/utils/process-image-upload.ts b/server/utils/process-image-upload.ts index c3c50b5..aea4f44 100644 --- a/server/utils/process-image-upload.ts +++ b/server/utils/process-image-upload.ts @@ -12,8 +12,8 @@ import { generateImageKey } from './image-key' import { buildImageItem, sanitizeOriginalName } from './image-response' import { getStorageLayout, getWebpQuality } from './env' import { - formatMaxFileSizeMessage, - getMaxFileSizeBytes, + formatEffectiveMaxFileSizeMessage, + getEffectiveMaxFileSizeBytes, isMimeTypeAllowed, isPreserveOriginalUpload } from './upload-policy' @@ -73,13 +73,13 @@ export async function processSingleImageUpload( const originalName = sanitizeOriginalName(input.filename ?? 'image') const bytes = input.bytes - if (bytes.byteLength > getMaxFileSizeBytes()) { + if (bytes.byteLength > getEffectiveMaxFileSizeBytes()) { return { error: { name: originalName, error: { code: 'FILE_TOO_LARGE', - message: formatMaxFileSizeMessage() + message: formatEffectiveMaxFileSizeMessage() } } } @@ -144,12 +144,16 @@ export async function processSingleImageUpload( name: originalName, source: input.source ?? 'web' }) + const detail = error instanceof Error ? error.message.trim() : '' + const message = detail.startsWith('Telegram') || detail.includes('群组') + ? detail + : '上传失败,请稍后重试' return { error: { name: originalName, error: { code: 'UPLOAD_FAILED', - message: '上传失败,请稍后重试' + message } } } diff --git a/server/utils/storage-backends.ts b/server/utils/storage-backends.ts index af313aa..fa6acf1 100644 --- a/server/utils/storage-backends.ts +++ b/server/utils/storage-backends.ts @@ -1,28 +1,99 @@ import type { DatabaseSync } from 'node:sqlite' import { getDb } from './db' import { LocalStorageBackend } from './storage/local' +import { deleteStorageObjectRefsForBackend } from './storage/storage-object-refs' import { parseS3Config, parseS3Secrets, S3StorageBackend } from './storage/s3' +import { + parseTelegramConfig, + parseTelegramSecrets, + TelegramStorageBackend +} from './storage/telegram' import type { S3BackendConfig, ServingMode, StorageBackendInfo, StorageBackendRow, - StorageBackendType + StorageBackendType, + TelegramBackendConfig, + TelegramBackendSecrets } from './storage/types' export const LOCAL_BACKEND_ID = 'local' export const S3_BACKEND_ID = 's3-primary' +const STORAGE_SCHEMA_TELEGRAM_KEY = 'storage_schema_telegram_v1' + +function migrateStorageBackendTelegramType(database: DatabaseSync): void { + const flag = database.prepare(` + SELECT value FROM settings WHERE key = ? + `).get(STORAGE_SCHEMA_TELEGRAM_KEY) as { value?: string } | undefined + if (flag?.value === '1') { + return + } + + const tableRow = database.prepare(` + SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'storage_backends' + `).get() as { sql?: string } | undefined + + const needsRebuild = Boolean( + tableRow?.sql && !tableRow.sql.includes('telegram') + ) + + if (!needsRebuild) { + database.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '1', ?) + `).run(STORAGE_SCHEMA_TELEGRAM_KEY, new Date().toISOString()) + return + } + + database.exec('PRAGMA foreign_keys = OFF') + database.exec('BEGIN') + try { + database.exec(` + CREATE TABLE storage_backends_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('local', 's3', 'telegram')), + config_json TEXT NOT NULL DEFAULT '{}', + secret_json TEXT NOT NULL DEFAULT '{}', + serving_mode TEXT NOT NULL DEFAULT 'proxy' CHECK(serving_mode IN ('proxy', 'public')), + public_url TEXT NOT NULL DEFAULT '', + quota_bytes INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + is_default INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `) + database.exec(` + INSERT INTO storage_backends_new + SELECT * FROM storage_backends + `) + database.exec('DROP TABLE storage_backends') + database.exec('ALTER TABLE storage_backends_new RENAME TO storage_backends') + database.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) + VALUES (?, '1', ?) + `).run(STORAGE_SCHEMA_TELEGRAM_KEY, new Date().toISOString()) + database.exec('COMMIT') + } catch (error) { + database.exec('ROLLBACK') + throw error + } finally { + database.exec('PRAGMA foreign_keys = ON') + } +} + function migrateStorageTables(database: DatabaseSync): void { database.exec(` CREATE TABLE IF NOT EXISTS storage_backends ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - type TEXT NOT NULL CHECK(type IN ('local', 's3')), + type TEXT NOT NULL CHECK(type IN ('local', 's3', 'telegram')), config_json TEXT NOT NULL DEFAULT '{}', secret_json TEXT NOT NULL DEFAULT '{}', serving_mode TEXT NOT NULL DEFAULT 'proxy' CHECK(serving_mode IN ('proxy', 'public')), @@ -47,7 +118,15 @@ function migrateStorageTables(database: DatabaseSync): void { CREATE INDEX IF NOT EXISTS idx_images_backend_id ON images(backend_id); CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id); CREATE INDEX IF NOT EXISTS idx_images_folder ON images(folder); + CREATE TABLE IF NOT EXISTS storage_object_refs ( + backend_id TEXT NOT NULL, + key TEXT NOT NULL, + ref_json TEXT NOT NULL, + PRIMARY KEY (backend_id, key), + FOREIGN KEY (backend_id) REFERENCES storage_backends(id) + ); `) + migrateStorageBackendTelegramType(database) } export function ensureStorageSchema(): void { @@ -109,7 +188,9 @@ function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendI } { const config = row.type === 's3' ? (parseS3Config(row.config_json) ?? {}) - : {} + : row.type === 'telegram' + ? (parseTelegramConfig(row.config_json) ?? {}) + : {} let secretsMasked: Record = {} if (row.type === 's3') { const secrets = parseS3Secrets(row.secret_json) @@ -121,6 +202,11 @@ function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendI ? maskSecret(secrets.secretAccessKey) : '' } + } else if (row.type === 'telegram') { + const secrets = parseTelegramSecrets(row.secret_json) + secretsMasked = { + botToken: secrets?.botToken ? maskSecret(secrets.botToken) : '' + } } return { @@ -213,7 +299,11 @@ export function setDefaultBackend(id: string): void { export interface StorageBackendPatch { name?: string config?: Record - secrets?: { accessKeyId?: string, secretAccessKey?: string } + secrets?: { + accessKeyId?: string + secretAccessKey?: string + botToken?: string + } servingMode?: ServingMode publicUrl?: string quotaBytes?: number | null @@ -234,7 +324,9 @@ export function updateStorageBackend(id: string, patch: StorageBackendPatch): vo if (patch.config !== undefined) { const current = row.type === 's3' ? (parseS3Config(row.config_json) ?? {}) - : {} + : row.type === 'telegram' + ? (parseTelegramConfig(row.config_json) ?? { chatId: '' }) + : {} configJson = JSON.stringify({ ...current, ...patch.config }) } @@ -254,7 +346,20 @@ export function updateStorageBackend(id: string, patch: StorageBackendPatch): vo secretJson = JSON.stringify(next) } - const servingMode = patch.servingMode ?? row.serving_mode + if (patch.secrets !== undefined && row.type === 'telegram') { + const current = parseTelegramSecrets(row.secret_json) ?? { botToken: '' } + const next = { + botToken: patch.secrets.botToken?.trim() + ? patch.secrets.botToken.trim() + : current.botToken + } + secretJson = JSON.stringify(next) + } + + let servingMode = patch.servingMode ?? row.serving_mode + if (row.type === 'telegram') { + servingMode = 'proxy' + } const publicUrl = patch.publicUrl ?? row.public_url const quotaBytes = patch.quotaBytes !== undefined ? patch.quotaBytes @@ -323,6 +428,15 @@ export function createBackendInstance(row: StorageBackendRow) { ) } + if (row.type === 'telegram') { + const config = parseTelegramConfig(row.config_json) + const secrets = parseTelegramSecrets(row.secret_json) + if (!config || !secrets) { + throw new Error(`Invalid Telegram configuration for backend ${row.id}`) + } + return new TelegramStorageBackend(row.id, config, secrets) + } + const config = parseS3Config(row.config_json) const secrets = parseS3Secrets(row.secret_json) if (!config || !secrets) { @@ -368,7 +482,7 @@ function getNextSortOrder(): number { return row.max_order + 1 } -export type ObjectStorageIdPrefix = 'r2' | 'cos' | 'oss' | 's3' +export type ObjectStorageIdPrefix = 'r2' | 'cos' | 'oss' | 's3' | 'tg' /** 根据表单预设或 endpoint 推断新建后端的 id 前缀 */ export function resolveObjectStorageIdPrefix( @@ -438,6 +552,41 @@ export function insertStorageBackend(input: { return id } +export function insertTelegramBackend(input: { + id?: string + name: string + config: TelegramBackendConfig + secrets: TelegramBackendSecrets + quotaBytes?: number | null + enabled?: boolean + isDefault?: boolean +}): string { + ensureDefaultBackends() + const id = input.id ?? generateStorageBackendId('tg') + const sortOrder = getNextSortOrder() + + getDb().prepare(` + INSERT INTO storage_backends + (id, name, type, config_json, secret_json, serving_mode, public_url, + quota_bytes, enabled, is_default, sort_order) + VALUES (?, ?, 'telegram', ?, ?, 'proxy', '', ?, ?, 0, ?) + `).run( + id, + input.name, + JSON.stringify(input.config), + JSON.stringify(input.secrets), + input.quotaBytes ?? null, + input.enabled === false ? 0 : 1, + sortOrder + ) + + if (input.isDefault) { + setDefaultBackend(id) + } + + return id +} + export function deleteStorageBackend(id: string): void { if (id === LOCAL_BACKEND_ID) { throw new Error('Cannot delete local backend') @@ -457,5 +606,6 @@ export function deleteStorageBackend(id: string): void { throw new Error('Cannot delete the default backend') } + deleteStorageObjectRefsForBackend(id) getDb().prepare('DELETE FROM storage_backends WHERE id = ?').run(id) } diff --git a/server/utils/storage-capacity.ts b/server/utils/storage-capacity.ts index aae2dc7..e079ed8 100644 --- a/server/utils/storage-capacity.ts +++ b/server/utils/storage-capacity.ts @@ -51,11 +51,11 @@ function buildQuotaCapacity(quotaBytes: number, indexBytes: number): StorageCapa export async function buildBackendCapacity( backendId: string, - backendType: 'local' | 's3', + backendType: 'local' | 's3' | 'telegram', indexBytes: number, quotaBytes: number | null ): Promise { - if (backendType === 's3') { + if (backendType === 's3' || backendType === 'telegram') { if (quotaBytes && quotaBytes > 0) { return buildQuotaCapacity(quotaBytes, indexBytes) } diff --git a/server/utils/storage/index.ts b/server/utils/storage/index.ts index 90362ad..5b13899 100644 --- a/server/utils/storage/index.ts +++ b/server/utils/storage/index.ts @@ -1,3 +1,10 @@ export { getActiveBackend, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver' export { LocalStorageBackend } from './local' export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3' +export { + TelegramStorageBackend, + parseTelegramConfig, + parseTelegramSecrets, + validateTelegramConfig, + TELEGRAM_MAX_FILE_BYTES +} from './telegram' diff --git a/server/utils/storage/storage-object-refs.ts b/server/utils/storage/storage-object-refs.ts new file mode 100644 index 0000000..096e8a0 --- /dev/null +++ b/server/utils/storage/storage-object-refs.ts @@ -0,0 +1,44 @@ +import { getDb } from '../db' +import type { TelegramObjectRef } from './types' + +export function getStorageObjectRef( + backendId: string, + key: string +): TelegramObjectRef | null { + const row = getDb().prepare(` + SELECT ref_json FROM storage_object_refs + WHERE backend_id = ? AND key = ? + `).get(backendId, key) as { ref_json: string } | undefined + + if (!row) return null + + try { + return JSON.parse(row.ref_json) as TelegramObjectRef + } catch { + return null + } +} + +export function setStorageObjectRef( + backendId: string, + key: string, + ref: TelegramObjectRef +): void { + getDb().prepare(` + INSERT INTO storage_object_refs (backend_id, key, ref_json) + VALUES (?, ?, ?) + ON CONFLICT(backend_id, key) DO UPDATE SET ref_json = excluded.ref_json + `).run(backendId, key, JSON.stringify(ref)) +} + +export function deleteStorageObjectRef(backendId: string, key: string): void { + getDb().prepare(` + DELETE FROM storage_object_refs WHERE backend_id = ? AND key = ? + `).run(backendId, key) +} + +export function deleteStorageObjectRefsForBackend(backendId: string): void { + getDb().prepare(` + DELETE FROM storage_object_refs WHERE backend_id = ? + `).run(backendId) +} diff --git a/server/utils/storage/telegram.test.ts b/server/utils/storage/telegram.test.ts new file mode 100644 index 0000000..42afecb --- /dev/null +++ b/server/utils/storage/telegram.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { + parseTelegramConfig, + parseTelegramSecrets, + validateApiProxyUrl, + validateTelegramConfig, + TelegramStorageBackend, + TELEGRAM_MAX_FILE_BYTES +} from './telegram' +import { + deleteStorageObjectRef, + getStorageObjectRef +} from './storage-object-refs' +import { + ensureDefaultBackends, + ensureStorageSchema, + getStorageBackendRow, + insertTelegramBackend +} from '../storage-backends' + +describe('telegram config', () => { + it('parses chat id and optional proxy', () => { + const config = parseTelegramConfig(JSON.stringify({ + chatId: '-1001', + apiProxyUrl: 'http://127.0.0.1:7890' + })) + expect(config).toEqual({ + chatId: '-1001', + apiProxyUrl: 'http://127.0.0.1:7890' + }) + }) + + it('rejects invalid proxy protocol', () => { + expect(validateApiProxyUrl('socks5://127.0.0.1:1080')).toMatch(/http/) + }) + + it('accepts http proxy', () => { + expect(validateApiProxyUrl('http://127.0.0.1:7890')).toBeNull() + }) + + it('validates required chat id', () => { + expect(validateTelegramConfig({ chatId: '' })).toBeTruthy() + expect(validateTelegramConfig({ chatId: '-100' })).toBeNull() + }) + + it('parses bot token secret', () => { + expect(parseTelegramSecrets(JSON.stringify({ botToken: '123:abc' }))).toEqual({ + botToken: '123:abc' + }) + }) + + it('exposes 20MB limit constant', () => { + expect(TELEGRAM_MAX_FILE_BYTES).toBe(20 * 1024 * 1024) + }) +}) + +describe('TelegramStorageBackend', () => { + const backendId = 'tg-test' + const key = 'images/2026/01/test.webp' + + beforeEach(() => { + ensureStorageSchema() + ensureDefaultBackends() + if (!getStorageBackendRow(backendId)) { + insertTelegramBackend({ + id: backendId, + name: 'TG test', + config: { chatId: '-100' }, + secrets: { botToken: 'token' }, + quotaBytes: 1024 * 1024 * 1024, + enabled: false + }) + } + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + deleteStorageObjectRef(backendId, key) + vi.unstubAllGlobals() + }) + + it('retries sendDocument after supergroup chat id migration', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: false, + description: 'Bad Request: group chat was upgraded to a supergroup chat', + parameters: { migrate_to_chat_id: -100999 } + }) + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { + message_id: 99, + document: { file_id: 'MIG', file_unique_id: 'UQ2' } + } + }) + } as Response) + + const backend = new TelegramStorageBackend( + backendId, + { chatId: '-123' }, + { botToken: 'token' } + ) + + await backend.put(key, new Uint8Array([4, 5]), { + originalName: 'test.webp', + uploadedAt: new Date().toISOString(), + contentType: 'image/webp', + size: 2 + }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + const ref = getStorageObjectRef(backendId, key) + expect(ref?.fileId).toBe('MIG') + }) + + it('put accepts a single photo object and sticker file id', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { + message_id: 8, + sticker: { file_id: 'stk', file_unique_id: 'us' } + } + }) + } as Response) + + const backend = new TelegramStorageBackend( + backendId, + { chatId: '-100' }, + { botToken: 'token' } + ) + + await backend.put(key, new Uint8Array([1]), { + originalName: 'test.webp', + uploadedAt: new Date().toISOString(), + contentType: 'image/webp', + size: 1 + }) + + expect(getStorageObjectRef(backendId, key)?.fileId).toBe('stk') + }) + + it('put accepts photo array when document is absent', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { + message_id: 7, + photo: [ + { file_id: 'small', file_unique_id: 'u1', width: 90, height: 90 }, + { file_id: 'large', file_unique_id: 'u2', width: 800, height: 600 } + ] + } + }) + } as Response) + + const backend = new TelegramStorageBackend( + backendId, + { chatId: '-100' }, + { botToken: 'token' } + ) + + await backend.put(key, new Uint8Array([1]), { + originalName: 'test.png', + uploadedAt: new Date().toISOString(), + contentType: 'image/png', + size: 1 + }) + + const ref = getStorageObjectRef(backendId, key) + expect(ref?.fileId).toBe('large') + }) + + it('put stores file ref after sendDocument', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { + message_id: 42, + document: { + file_id: 'AAA', + file_unique_id: 'UQ' + } + } + }) + } as Response) + + const backend = new TelegramStorageBackend( + backendId, + { chatId: '-100' }, + { botToken: 'token' } + ) + + await backend.put(key, new Uint8Array([1, 2, 3]), { + originalName: 'test.webp', + uploadedAt: new Date().toISOString(), + contentType: 'image/webp', + size: 3 + }) + + const ref = getStorageObjectRef(backendId, key) + expect(ref?.fileId).toBe('AAA') + expect(ref?.messageId).toBe(42) + }) + + it('testConnection reports telegram errors', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: false, description: 'Unauthorized' }) + } as Response) + + const backend = new TelegramStorageBackend( + backendId, + { chatId: '-100' }, + { botToken: 'bad' } + ) + + const result = await backend.testConnection() + expect(result.ok).toBe(false) + expect(result.message).toContain('Unauthorized') + }) + + it('createStream downloads file bytes', async () => { + const fetchMock = vi.mocked(fetch) + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { + message_id: 1, + document: { file_id: 'FILE1', file_unique_id: 'U1' } + } + }) + } as Response) + + const putBackend = new TelegramStorageBackend( + backendId, + { chatId: '-100' }, + { botToken: 'token' } + ) + await putBackend.put(key, new Uint8Array([1]), { + originalName: 'test.webp', + uploadedAt: new Date().toISOString(), + contentType: 'image/webp', + size: 1 + }) + + fetchMock.mockReset() + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { file_id: 'FILE1', file_path: 'photos/file.jpg' } + }) + } as Response) + .mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: async () => new Uint8Array([9, 8, 7]).buffer + } as Response) + + const backend = new TelegramStorageBackend( + backendId, + { chatId: '-100' }, + { botToken: 'token' } + ) + + const stream = await backend.createStream(key) + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + expect(Buffer.concat(chunks)).toEqual(Buffer.from([9, 8, 7])) + }) +}) diff --git a/server/utils/storage/telegram.ts b/server/utils/storage/telegram.ts new file mode 100644 index 0000000..0950aed --- /dev/null +++ b/server/utils/storage/telegram.ts @@ -0,0 +1,510 @@ +import { Readable } from 'node:stream' +import { getImageIndexAsStored } from '../image-index' +import { validateImageKey } from '../image-key' +import { logException } from '../logger' +import { + deleteStorageObjectRef, + getStorageObjectRef, + setStorageObjectRef +} from './storage-object-refs' +import type { + ServingMode, + StoredImage, + StoredImageMeta, + StorageBackend, + TelegramBackendConfig, + TelegramBackendSecrets, + TelegramObjectRef +} from './types' + +export const TELEGRAM_MAX_FILE_BYTES = 20 * 1024 * 1024 +const TELEGRAM_API_BASE = 'https://api.telegram.org' + +export function parseTelegramConfig(raw: string): TelegramBackendConfig | null { + try { + const parsed = JSON.parse(raw) as Partial + if (!parsed.chatId?.trim()) return null + const apiProxyUrl = parsed.apiProxyUrl?.trim() + return { + chatId: parsed.chatId.trim(), + apiProxyUrl: apiProxyUrl || undefined + } + } catch { + return null + } +} + +export function parseTelegramSecrets(raw: string): TelegramBackendSecrets | null { + try { + const parsed = JSON.parse(raw) as Partial + if (!parsed.botToken?.trim()) return null + return { botToken: parsed.botToken.trim() } + } catch { + return null + } +} + +export function validateTelegramConfig(config: TelegramBackendConfig): string | null { + if (!config.chatId.trim()) { + return 'Chat ID 不能为空' + } + if (config.apiProxyUrl) { + const error = validateApiProxyUrl(config.apiProxyUrl) + if (error) return error + } + return null +} + +export function validateApiProxyUrl(url: string): string | null { + const trimmed = url.trim() + if (!trimmed) return null + try { + const parsed = new URL(trimmed) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return null + } + return 'API 代理仅支持 http:// 或 https:// 地址' + } catch { + return 'API 代理地址格式无效' + } +} + +interface TelegramApiResponse { + ok: boolean + result?: T + description?: string + parameters?: { + retry_after?: number + migrate_to_chat_id?: number + } +} + +export class TelegramApiCallError extends Error { + readonly migrateToChatId?: number + + constructor(description: string, migrateToChatId?: number) { + super(formatTelegramApiErrorMessage(description, migrateToChatId)) + this.name = 'TelegramApiCallError' + this.migrateToChatId = migrateToChatId + } +} + +function formatTelegramApiErrorMessage( + description: string, + migrateToChatId?: number +): string { + if (migrateToChatId != null) { + return `Telegram 群组已升级,请使用 Chat ID:${migrateToChatId}` + } + const lower = description.toLowerCase() + if (lower.includes('upgraded to a supergroup')) { + return '群组已升级为超级群组,请在存储设置中更新 Chat ID(新 ID 一般为 -100 开头,可向 @GetTheirIDBot 查询)' + } + return description +} + +interface TelegramFileIdentity { + file_id: string + file_unique_id: string +} + +interface TelegramSentMessage { + message_id: number + document?: TelegramFileIdentity & { file_name?: string, thumbnail?: TelegramFileIdentity } + photo?: TelegramFileIdentity[] | TelegramFileIdentity + animation?: TelegramFileIdentity & { thumbnail?: TelegramFileIdentity } + video?: TelegramFileIdentity & { thumbnail?: TelegramFileIdentity } + sticker?: TelegramFileIdentity & { thumbnail?: TelegramFileIdentity } + audio?: TelegramFileIdentity + voice?: TelegramFileIdentity + video_note?: TelegramFileIdentity +} + +function asFileIdentity(value: unknown): TelegramFileIdentity | null { + if (!value || typeof value !== 'object') return null + const rec = value as Partial + if (typeof rec.file_id !== 'string' || !rec.file_id) return null + return { + file_id: rec.file_id, + file_unique_id: typeof rec.file_unique_id === 'string' ? rec.file_unique_id : '' + } +} + +function photoList(photo: TelegramSentMessage['photo']): TelegramFileIdentity[] { + if (!photo) return [] + if (Array.isArray(photo)) { + return photo.map(asFileIdentity).filter((item): item is TelegramFileIdentity => item != null) + } + const single = asFileIdentity(photo) + return single ? [single] : [] +} + +function fileRefFromSentMessage( + message: TelegramSentMessage +): TelegramObjectRef | null { + const messageId = message.message_id + const document = asFileIdentity(message.document) + if (document) { + return { + fileId: document.file_id, + fileUniqueId: document.file_unique_id, + messageId + } + } + + const photos = photoList(message.photo) + const largest = photos.at(-1) + if (largest) { + return { + fileId: largest.file_id, + fileUniqueId: largest.file_unique_id, + messageId + } + } + + for (const media of [ + message.sticker, + message.animation, + message.video, + message.audio, + message.voice, + message.video_note + ]) { + const file = asFileIdentity(media) + if (file) { + return { + fileId: file.file_id, + fileUniqueId: file.file_unique_id, + messageId + } + } + } + + return null +} + +interface TelegramFile { + file_id: string + file_unique_id: string + file_path?: string + file_size?: number +} + +function apiUrl(botToken: string, method: string): string { + return `${TELEGRAM_API_BASE}/bot${botToken}/${method}` +} + +function fileDownloadUrl(botToken: string, filePath: string): string { + return `${TELEGRAM_API_BASE}/file/bot${botToken}/${filePath}` +} + +async function telegramFetch( + url: string, + init: RequestInit, + apiProxyUrl?: string +): Promise { + const proxy = apiProxyUrl?.trim() + if (!proxy) { + return fetch(url, init) + } + + const undici = await import('undici') + const dispatcher = new undici.ProxyAgent(proxy) + const response = await undici.fetch(url, { + ...init, + dispatcher + } as Parameters[1]) + return response as unknown as Response +} + +async function callTelegramApi( + botToken: string, + method: string, + init: RequestInit, + apiProxyUrl?: string, + attempt = 0 +): Promise { + const response = await telegramFetch(apiUrl(botToken, method), init, apiProxyUrl) + const body = await response.json() as TelegramApiResponse + + if (!body.ok) { + const retryAfter = body.parameters?.retry_after + if (response.status === 429 && retryAfter && attempt < 2) { + await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)) + return callTelegramApi(botToken, method, init, apiProxyUrl, attempt + 1) + } + const description = body.description ?? `HTTP ${response.status}` + if (response.status === 429 || description.toLowerCase().includes('too many')) { + throw new Error('Telegram 请求过于频繁,请稍后重试') + } + const migrateToChatId = body.parameters?.migrate_to_chat_id + throw new TelegramApiCallError(description, migrateToChatId) + } + + if (body.result === undefined) { + throw new Error('Telegram API 返回为空') + } + + return body.result +} + +function filenameFromKey(key: string): string { + const base = key.split('/').pop() ?? 'image' + return base.replace(/[^\w.-]+/g, '_').slice(0, 200) || 'image' +} + +/** Node fetch + FormData/Blob 发往 Telegram 时经常丢文件,改用手写 multipart。 */ +function buildSendDocumentMultipart(input: { + chatId: string + caption: string + filename: string + bytes: Uint8Array +}): { body: Uint8Array, contentType: string } { + const boundary = `----PicHost${crypto.randomUUID().replace(/-/g, '')}` + const crlf = '\r\n' + const safeFilename = input.filename.replace(/"/g, '\\"') + const preamble = [ + `--${boundary}${crlf}`, + `Content-Disposition: form-data; name="chat_id"${crlf}${crlf}`, + `${input.chatId}${crlf}`, + `--${boundary}${crlf}`, + `Content-Disposition: form-data; name="disable_content_type_detection"${crlf}${crlf}`, + `true${crlf}`, + `--${boundary}${crlf}`, + `Content-Disposition: form-data; name="caption"${crlf}${crlf}`, + `${input.caption}${crlf}`, + `--${boundary}${crlf}`, + `Content-Disposition: form-data; name="document"; filename="${safeFilename}"${crlf}`, + // 固定 octet-stream,避免 Telegram 把图片转成 photo 后响应里不带 file_id + `Content-Type: application/octet-stream${crlf}${crlf}` + ].join('') + + const suffix = `${crlf}--${boundary}--${crlf}` + const body = Buffer.concat([ + Buffer.from(preamble, 'utf8'), + Buffer.from(input.bytes), + Buffer.from(suffix, 'utf8') + ]) + + return { + body: new Uint8Array(body), + contentType: `multipart/form-data; boundary=${boundary}` + } +} + +export class TelegramStorageBackend implements StorageBackend { + readonly id: string + readonly type = 'telegram' as const + readonly servingMode: ServingMode = 'proxy' + readonly publicUrl = '' + private chatId: string + private readonly botToken: string + private readonly apiProxyUrl?: string + + constructor( + id: string, + config: TelegramBackendConfig, + secrets: TelegramBackendSecrets + ) { + this.id = id + this.chatId = config.chatId + this.botToken = secrets.botToken + this.apiProxyUrl = config.apiProxyUrl + } + + private async persistChatIdMigration(newChatId: string): Promise { + if (newChatId === this.chatId) return + this.chatId = newChatId + const { updateStorageBackend } = await import('../storage-backends') + updateStorageBackend(this.id, { config: { chatId: newChatId } }) + } + + private async withChatIdMigrationRetry( + fn: (chatId: string) => Promise + ): Promise { + try { + return await fn(this.chatId) + } catch (error) { + if ( + error instanceof TelegramApiCallError + && error.migrateToChatId != null + ) { + const newId = String(error.migrateToChatId) + await this.persistChatIdMigration(newId) + return await fn(newId) + } + throw error + } + } + + async put(key: string, bytes: Uint8Array, _meta: StoredImageMeta): Promise { + if (!validateImageKey(key)) { + throw new Error('Invalid image key') + } + if (bytes.byteLength > TELEGRAM_MAX_FILE_BYTES) { + throw new Error('文件超过 Telegram 存储上限(20MB)') + } + + const filename = filenameFromKey(key) + const caption = key.length <= 1024 ? key : key.slice(0, 1021) + '...' + + const message = await this.withChatIdMigrationRetry(async (chatId) => { + const { body, contentType: multipartType } = buildSendDocumentMultipart({ + chatId, + caption, + filename, + bytes + }) + return callTelegramApi( + this.botToken, + 'sendDocument', + { + method: 'POST', + headers: { 'Content-Type': multipartType }, + body: body.buffer.slice( + body.byteOffset, + body.byteOffset + body.byteLength + ) as ArrayBuffer + }, + this.apiProxyUrl + ) + }) + + const ref = fileRefFromSentMessage(message) + if (!ref) { + if (message.message_id != null) { + await this.deleteChatMessage(this.chatId, message.message_id).catch((error) => { + logException('telegram rollback message failed', error, { + messageId: message.message_id + }) + }) + } + logException( + 'telegram sendDocument: message has no file id', + new Error('Telegram 未返回文件信息'), + { + messageId: message.message_id, + keys: Object.keys(message) + } + ) + throw new Error('Telegram 未返回文件信息') + } + setStorageObjectRef(this.id, key, ref) + } + + async head(key: string): Promise { + if (!validateImageKey(key)) return null + + const indexed = getImageIndexAsStored(key) + if (!indexed || indexed.backendId !== this.id) { + return null + } + + const ref = getStorageObjectRef(this.id, key) + if (!ref) return null + + return { ...indexed, backendId: this.id } + } + + private async deleteChatMessage(chatId: string, messageId: number): Promise { + await callTelegramApi( + this.botToken, + 'deleteMessage', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + message_id: messageId + }) + }, + this.apiProxyUrl + ) + } + + async delete(key: string): Promise { + const ref = getStorageObjectRef(this.id, key) + deleteStorageObjectRef(this.id, key) + + if (ref?.messageId == null) return + + try { + await this.withChatIdMigrationRetry(chatId => + this.deleteChatMessage(chatId, ref.messageId as number) + ) + } catch (error) { + logException('telegram deleteMessage failed', error, { key, messageId: ref.messageId }) + } + } + + async createStream( + key: string, + range?: { start: number, end: number } + ): Promise { + const ref = getStorageObjectRef(this.id, key) + if (!ref?.fileId) { + throw new Error('Telegram 文件引用不存在') + } + + const file = await callTelegramApi( + this.botToken, + 'getFile', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_id: ref.fileId }) + }, + this.apiProxyUrl + ) + + if (!file.file_path) { + throw new Error('Telegram 文件路径不可用') + } + + const downloadUrl = fileDownloadUrl(this.botToken, file.file_path) + const response = await telegramFetch(downloadUrl, { method: 'GET' }, this.apiProxyUrl) + if (!response.ok) { + throw new Error(`Telegram 文件下载失败(${response.status})`) + } + + const buffer = Buffer.from(await response.arrayBuffer()) + + if (range) { + const slice = buffer.subarray(range.start, range.end + 1) + return Readable.from(slice) + } + + return Readable.from(buffer) + } + + async testConnection(): Promise<{ ok: boolean, message: string }> { + try { + await callTelegramApi<{ id: number, username?: string }>( + this.botToken, + 'getMe', + { method: 'GET' }, + this.apiProxyUrl + ) + await this.withChatIdMigrationRetry(chatId => + callTelegramApi<{ id: number, title?: string }>( + this.botToken, + 'getChat', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: chatId }) + }, + this.apiProxyUrl + ) + ) + return { ok: true, message: '连接成功' } + } catch (error) { + const message = error instanceof Error ? error.message : '连接失败' + return { ok: false, message } + } + } + + async getUsageStats(): Promise<{ count: number, bytes: number }> { + return { count: 0, bytes: 0 } + } +} diff --git a/server/utils/storage/types.ts b/server/utils/storage/types.ts index 7e89538..a0ef49b 100644 --- a/server/utils/storage/types.ts +++ b/server/utils/storage/types.ts @@ -28,7 +28,7 @@ export interface FolderStorageStat { bytes: number } -export type StorageBackendType = 'local' | 's3' +export type StorageBackendType = 'local' | 's3' | 'telegram' export type ServingMode = 'proxy' | 'public' export interface S3BackendConfig { @@ -44,6 +44,21 @@ export interface S3BackendSecrets { secretAccessKey: string } +export interface TelegramBackendConfig { + chatId: string + apiProxyUrl?: string +} + +export interface TelegramBackendSecrets { + botToken: string +} + +export interface TelegramObjectRef { + fileId: string + fileUniqueId?: string + messageId?: number +} + export interface StorageBackendRow { id: string name: string @@ -62,7 +77,7 @@ export interface StorageBackendInfo { id: string name: string type: StorageBackendType - config: S3BackendConfig | Record + config: S3BackendConfig | TelegramBackendConfig | Record servingMode: ServingMode publicUrl: string quotaBytes: number | null diff --git a/server/utils/upload-policy.ts b/server/utils/upload-policy.ts index 0dabd82..553b716 100644 --- a/server/utils/upload-policy.ts +++ b/server/utils/upload-policy.ts @@ -3,6 +3,8 @@ import { type AllowedMimeType } from './constants' import { getSetting, setSetting } from './db' +import { getActiveBackendRow } from './storage/resolver' +import { TELEGRAM_MAX_FILE_BYTES } from './storage/telegram' export const SETTINGS_UPLOAD_MAX_FILE_SIZE_MB = 'upload_max_file_size_mb' export const SETTINGS_ALLOWED_MIME_TYPES = 'allowed_mime_types' @@ -180,6 +182,15 @@ export function getMaxFileSizeBytes(): number { return getUploadMaxFileSizeMb() * 1024 * 1024 } +export function getEffectiveMaxFileSizeBytes(): number { + const configured = getMaxFileSizeBytes() + const row = getActiveBackendRow() + if (row?.type === 'telegram') { + return Math.min(configured, TELEGRAM_MAX_FILE_BYTES) + } + return configured +} + export function getAllowedMimeTypesList(): AllowedMimeType[] { const dbRaw = getSetting(SETTINGS_ALLOWED_MIME_TYPES) if (dbRaw !== null) { @@ -279,6 +290,19 @@ export function formatMaxFileSizeMessage(): string { return `图片大小不能超过 ${getUploadMaxFileSizeMb()} MB` } +export function formatEffectiveMaxFileSizeMessage(): string { + const row = getActiveBackendRow() + if (row?.type === 'telegram') { + const mb = TELEGRAM_MAX_FILE_BYTES / (1024 * 1024) + const siteMb = getUploadMaxFileSizeMb() + if (siteMb * 1024 * 1024 <= TELEGRAM_MAX_FILE_BYTES) { + return formatMaxFileSizeMessage() + } + return `图片大小不能超过 ${mb} MB(Telegram 存储上限)` + } + return formatMaxFileSizeMessage() +} + export function getUploadPolicySettingsPayload() { return { uploadMaxFileSizeMb: getUploadMaxFileSizeMb(),