4 Commits
Author SHA1 Message Date
O96u 05cc3895f1 release: v1.4.4
docs / build (push) Canceled after 0s
docs / deploy (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Update Cap login verification so hashwx challenges solve, while older SHA-256 keys still work.
2026-10-10 13:26:42 +08:00
O96u 424bfd9090 fix: give each test worker its own SQLite database
Parallel Vitest workers were opening data/pichost.db together, so host-isolation failed with database is locked.
2026-10-09 15:52:03 +08:00
O96u 10baf9d489 fix: run Telegram storage tests on an isolated database
CI workers were sharing one SQLite file, so schema setup locked and storage_object_refs was missing.
2026-10-09 15:47:09 +08:00
O96u cc19dc6435 release: v1.4.3
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
Add a Telegram channel or group storage backend, with proxied image URLs and an optional API proxy.
2026-10-09 15:39:24 +08:00
37 changed files with 1547 additions and 67 deletions

No files matched your search

+20
View File
@@ -4,6 +4,26 @@
## [Unreleased] ## [Unreleased]
## [1.4.4] - 2026-10-10
### 修复
- Cap 登录验证支持新的 hashwx 挑战,旧的 SHA-256 site key 仍可使用
- 求解用的 wasm 改由本站提供,不再依赖 jsDelivr
## [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 ## [1.4.2] - 2026-09-23
### 修复 ### 修复
+2 -2
View File
@@ -55,7 +55,7 @@
- **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection - **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-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 - **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 - **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` - **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 | | Backend | [Nitro](https://nitro.build) (`node-server`) · REST API |
| Data | SQLite · local `data/pichost.db` | | Data | SQLite · local `data/pichost.db` |
| Images | [sharp](https://sharp.pixelplumbing.com) (server-side WebP) | | 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) | | i18n | [@nuxtjs/i18n](https://i18n.nuxtjs.org) (zh-CN / English) |
| Quality & docs | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) docs site | | Quality & docs | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) docs site |
| Deploy | Docker · Node.js 22 | | Deploy | Docker · Node.js 22 |
+2 -2
View File
@@ -55,7 +55,7 @@
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链 - **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
- **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片 - **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public` - **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS)+ Telegram 频道/群组;混合直链 `proxy` / `public`
- **图库**:浏览、搜索、按存储/来源/标签筛选、网格/列表切换、批量删除与批量打标;统计概览与来源分布;点击图片查看详情与多格式链接 - **图库**:浏览、搜索、按存储/来源/标签筛选、网格/列表切换、批量删除与批量打标;统计概览与来源分布;点击图片查看详情与多格式链接
- **标签**:每用户独立标签、彩色标记;上传后单张/批量打标;设置页管理(新建、改色、合并、删除);API 支持 `tagIds` 与批量打标 - **标签**:每用户独立标签、彩色标记;上传后单张/批量打标;设置页管理(新建、改色、合并、删除);API 支持 `tagIds` 与批量打标
- **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复 - **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复
@@ -70,7 +70,7 @@
| 后端 | [Nitro](https://nitro.build)(`node-server`)· REST API | | 后端 | [Nitro](https://nitro.build)(`node-server`)· REST API |
| 数据 | SQLite · 本地 `data/pichost.db` | | 数据 | SQLite · 本地 `data/pichost.db` |
| 图片处理 | [sharp](https://sharp.pixelplumbing.com)(服务端 WebP 压缩) | | 图片处理 | [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) | | 国际化 | [@nuxtjs/i18n](https://i18n.nuxtjs.org)(简体中文 / English) |
| 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 | | 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 |
| 部署 | Docker · Node.js 22 | | 部署 | Docker · Node.js 22 |
+14 -2
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import 'cap-widget'
const props = defineProps<{ const props = defineProps<{
apiEndpoint: string apiEndpoint: string
}>() }>()
@@ -13,6 +11,19 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const widgetRef = ref<HTMLElement | null>(null) const widgetRef = ref<HTMLElement | null>(null)
const ready = ref(false)
onMounted(async () => {
const capWindow = window as Window & {
CAP_CUSTOM_WASM_URL?: string
CAP_CUSTOM_HASHWX_URL?: string
}
// SHA-256 与 hashwx 的求解器都放在本站 /cap,避免组件去 jsDelivr 拉 wasm。
capWindow.CAP_CUSTOM_WASM_URL = '/cap/cap_wasm_bg.wasm'
capWindow.CAP_CUSTOM_HASHWX_URL = '/cap/hashwx.wasm'
await import('cap-widget')
ready.value = true
})
function onSolve(event: Event) { function onSolve(event: Event) {
const token = (event as CustomEvent<{ token: string }>).detail?.token const token = (event as CustomEvent<{ token: string }>).detail?.token
@@ -37,6 +48,7 @@ defineExpose({ reset })
<template> <template>
<ClientOnly> <ClientOnly>
<cap-widget <cap-widget
v-if="ready"
ref="widgetRef" ref="widgetRef"
:data-cap-api-endpoint="props.apiEndpoint" :data-cap-api-endpoint="props.apiEndpoint"
:data-cap-i18n-initial-state="t('auth.capInitialState')" :data-cap-i18n-initial-state="t('auth.capInitialState')"
+11 -7
View File
@@ -21,13 +21,17 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const typeIcon = computed(() => const typeIcon = computed(() => {
props.backend.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud' 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(() => const typeLabel = computed(() => {
props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud') 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) const actionsDisabled = computed(() => props.envOverride || props.busy)
@@ -38,7 +42,7 @@ function canSetDefault() {
} }
function canEdit() { function canEdit() {
return props.backend.type === 's3' return props.backend.type === 's3' || props.backend.type === 'telegram'
} }
function canToggle() { function canToggle() {
+107 -9
View File
@@ -2,9 +2,10 @@
import type { QuotaUnit } from '~/composables/useFileSize' import type { QuotaUnit } from '~/composables/useFileSize'
import type { StorageBackendItem } from '~/types/storage' import type { StorageBackendItem } from '~/types/storage'
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom' type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom' | 'telegram'
export interface StorageFormPayload { export interface StorageFormPayload {
backendType: 's3' | 'telegram'
name: string name: string
provider: StorageProvider provider: StorageProvider
config: { config: {
@@ -13,10 +14,13 @@ export interface StorageFormPayload {
bucket: string bucket: string
prefix?: string prefix?: string
forcePathStyle: boolean forcePathStyle: boolean
chatId?: string
apiProxyUrl?: string
} }
secrets: { secrets: {
accessKeyId: string accessKeyId: string
secretAccessKey: string secretAccessKey: string
botToken: string
} }
servingMode: 'proxy' | 'public' servingMode: 'proxy' | 'public'
publicUrl: string publicUrl: string
@@ -47,11 +51,18 @@ const prefix = ref('')
const forcePathStyle = ref(false) const forcePathStyle = ref(false)
const accessKeyId = ref('') const accessKeyId = ref('')
const secretAccessKey = ref('') const secretAccessKey = ref('')
const chatId = ref('')
const botToken = ref('')
const apiProxyUrl = ref('')
const servingMode = ref<'proxy' | 'public'>('proxy') const servingMode = ref<'proxy' | 'public'>('proxy')
const publicUrl = ref('') const publicUrl = ref('')
const quotaValue = ref('') const quotaValue = ref('')
const quotaUnit = ref<QuotaUnit>('GB') const quotaUnit = ref<QuotaUnit>('GB')
const isTelegram = computed(() =>
provider.value === 'telegram' || props.backend?.type === 'telegram'
)
const quotaUnitOptions = computed(() => const quotaUnitOptions = computed(() =>
QUOTA_UNITS.map(unit => ({ label: unit, value: unit })) 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.providerCos'), value: 'cos' as const },
{ label: t('storage.providerOss'), value: 'oss' as const }, { label: t('storage.providerOss'), value: 'oss' as const },
{ label: t('storage.providerAws'), value: 'aws' 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(() => [ const servingModeOptions = computed(() => [
@@ -79,6 +91,10 @@ function applyProviderTemplate(value: StorageProvider) {
if (props.createMode) { if (props.createMode) {
name.value = providerLabel(value) name.value = providerLabel(value)
} }
if (value === 'telegram') {
servingMode.value = 'proxy'
return
}
switch (value) { switch (value) {
case 'r2': case 'r2':
endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com' endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com'
@@ -114,9 +130,7 @@ function detectProvider(config: StorageBackendItem['config']): StorageProvider {
return 'custom' return 'custom'
} }
function loadBackend(backend: StorageBackendItem | null | undefined) { function resetCreateDefaults() {
if (!backend || backend.type !== 's3') {
if (props.createMode) {
name.value = '' name.value = ''
provider.value = 'r2' provider.value = 'r2'
applyProviderTemplate('r2') applyProviderTemplate('r2')
@@ -124,11 +138,36 @@ function loadBackend(backend: StorageBackendItem | null | undefined) {
prefix.value = '' prefix.value = ''
accessKeyId.value = '' accessKeyId.value = ''
secretAccessKey.value = '' secretAccessKey.value = ''
chatId.value = ''
botToken.value = ''
apiProxyUrl.value = ''
servingMode.value = 'proxy' servingMode.value = 'proxy'
publicUrl.value = '' publicUrl.value = ''
quotaValue.value = '' quotaValue.value = ''
quotaUnit.value = 'GB' quotaUnit.value = 'GB'
}
function loadBackend(backend: StorageBackendItem | null | undefined) {
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 return
} }
@@ -157,7 +196,9 @@ watch(provider, (value) => {
}) })
function handleSubmit() { function handleSubmit() {
const backendType = isTelegram.value ? 'telegram' : 's3'
emit('submit', { emit('submit', {
backendType,
name: name.value.trim() || providerLabel(provider.value), name: name.value.trim() || providerLabel(provider.value),
provider: provider.value, provider: provider.value,
config: { config: {
@@ -165,13 +206,16 @@ function handleSubmit() {
region: region.value.trim() || 'auto', region: region.value.trim() || 'auto',
bucket: bucket.value.trim(), bucket: bucket.value.trim(),
prefix: prefix.value.trim() || undefined, prefix: prefix.value.trim() || undefined,
forcePathStyle: forcePathStyle.value forcePathStyle: forcePathStyle.value,
chatId: chatId.value.trim() || undefined,
apiProxyUrl: apiProxyUrl.value.trim() || undefined
}, },
secrets: { secrets: {
accessKeyId: accessKeyId.value.trim(), 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(), publicUrl: publicUrl.value.trim(),
quotaValue: String(quotaValue.value ?? '').trim(), quotaValue: String(quotaValue.value ?? '').trim(),
quotaUnit: quotaUnit.value quotaUnit: quotaUnit.value
@@ -190,6 +234,7 @@ function handleSubmit() {
v-model="provider" v-model="provider"
:items="providerOptions" :items="providerOptions"
class="w-full" class="w-full"
:disabled="!createMode && backend?.type === 'telegram'"
/> />
</div> </div>
@@ -201,6 +246,58 @@ function handleSubmit() {
/> />
</div> </div>
<template v-if="isTelegram">
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.telegramChatId') }}
<span class="text-error">*</span>
</label>
<UInput
v-model="chatId"
:placeholder="t('storage.telegramChatIdPlaceholder')"
class="w-full font-mono text-sm"
required
/>
<p class="text-xs text-muted">
{{ t('storage.telegramChatIdHint') }}
</p>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.telegramBotToken') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="botToken"
type="password"
:placeholder="backend?.secretsMasked.botToken || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.telegramApiProxy') }}</label>
<UInput
v-model="apiProxyUrl"
:placeholder="t('storage.telegramApiProxyPlaceholder')"
class="w-full font-mono text-sm"
/>
<p class="text-xs text-muted">
{{ t('storage.telegramApiProxyHint') }}
</p>
</div>
<p class="text-xs text-muted">
{{ t('storage.telegramServingHint') }}
</p>
</template>
<template v-else>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm">{{ t('storage.endpoint') }}</label> <label class="text-sm">{{ t('storage.endpoint') }}</label>
<UInput <UInput
@@ -300,6 +397,7 @@ function handleSubmit() {
class="w-full font-mono text-sm" class="w-full font-mono text-sm"
/> />
</div> </div>
</template>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-sm"> <label class="text-sm">
@@ -324,7 +422,7 @@ function handleSubmit() {
/> />
</div> </div>
<p class="text-xs text-muted"> <p class="text-xs text-muted">
{{ t('storage.quotaHint') }} {{ isTelegram ? t('storage.telegramQuotaHint') : t('storage.quotaHint') }}
</p> </p>
</div> </div>
@@ -22,6 +22,13 @@ const items = computed(() => [
boxClass: 'border-blue-500/20 bg-blue-500/5', boxClass: 'border-blue-500/20 bg-blue-500/5',
title: t('storage.supportedS3Title'), title: t('storage.supportedS3Title'),
desc: t('storage.supportedS3Desc') 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')
} }
]) ])
</script> </script>
+34 -3
View File
@@ -93,16 +93,42 @@ const { quotaToBytes } = useFileSize()
function buildPatchBody(payload: StorageFormPayload) { function buildPatchBody(payload: StorageFormPayload) {
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit) const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
if (payload.backendType === 'telegram') {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
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<string, unknown> = {
type: 's3',
name: payload.name, name: payload.name,
provider: payload.provider, provider: payload.provider,
servingMode: payload.servingMode, servingMode: payload.servingMode,
publicUrl: payload.publicUrl, publicUrl: payload.publicUrl,
quotaBytes, 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) { if (payload.secrets.accessKeyId || payload.secrets.secretAccessKey) {
body.secrets = payload.secrets body.secrets = {
accessKeyId: payload.secrets.accessKeyId,
secretAccessKey: payload.secrets.secretAccessKey
}
} }
return body return body
} }
@@ -128,7 +154,12 @@ async function onFormSubmit(payload: StorageFormPayload) {
} }
if (formMode.value === 'create') { 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' }) toast.add({ title: t('storage.secretsRequired'), color: 'warning' })
return return
} }
+1 -1
View File
@@ -40,7 +40,7 @@ export interface ImageItem {
storage?: { storage?: {
id: string id: string
name: string name: string
type: 'local' | 's3' type: 'local' | 's3' | 'telegram'
} }
/** 上传来源:网页或 API(来自活动日志) */ /** 上传来源:网页或 API(来自活动日志) */
uploadSource?: 'web' | 'api' uploadSource?: 'web' | 'api'
+4 -1
View File
@@ -11,17 +11,20 @@ export interface StorageCapacity {
export interface StorageBackendItem { export interface StorageBackendItem {
id: string id: string
name: string name: string
type: 'local' | 's3' type: 'local' | 's3' | 'telegram'
config: { config: {
endpoint?: string endpoint?: string
region?: string region?: string
bucket?: string bucket?: string
prefix?: string prefix?: string
forcePathStyle?: boolean forcePathStyle?: boolean
chatId?: string
apiProxyUrl?: string
} }
secretsMasked: { secretsMasked: {
accessKeyId?: string accessKeyId?: string
secretAccessKey?: string secretAccessKey?: string
botToken?: string
} }
servingMode: ServingMode servingMode: ServingMode
publicUrl: string publicUrl: string
+12
View File
@@ -4,6 +4,18 @@ Version history and notable changes. The full log is also in the repo root [CHAN
--- ---
## [1.4.4] — 2026-10-10
### Fixed
- Cap login verification supports hashwx challenges and still accepts older SHA-256 site keys
## [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 ## [1.4.2] — 2026-09-23
### Fixed ### Fixed
+21 -4
View File
@@ -1,6 +1,6 @@
# Storage # 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) ![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 ## Adding a backend
1. Sign in as admin → **Storage** → **Add backend**. 1. Sign in as admin → **Storage** → **Add backend**.
2. Choose **local disk** or **S3-compatible** (R2 / COS / OSS presets). 2. Choose **local disk**, **S3-compatible** (R2 / COS / OSS presets), or **Telegram channel**.
3. Enter connection details (bucket, endpoint, keys). 3. Enter connection details (bucket, endpoint, keys, or Bot Token / Chat ID).
4. **Set as default** so new uploads use it. 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 ### 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). 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) ### 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. 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.
+1 -1
View File
@@ -20,7 +20,7 @@ features:
- title: Drag, click, paste - title: Drag, click, paste
details: Server-side WebP compression and Referer hotlink protection. details: Server-side WebP compression and Referer hotlink protection.
- title: Multi-backend storage - 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 - title: Users & RBAC
details: Admin and regular users; global and per-user API tokens; gallery scoped by owner. details: Admin and regular users; global and per-user API tokens; gallery scoped by owner.
- title: Zero-config Docker - title: Zero-config Docker
+12
View File
@@ -4,6 +4,18 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
--- ---
## [1.4.4] — 2026-10-10
### 修复
- Cap 登录验证支持 hashwx,并继续兼容旧的 SHA-256 site key
## [1.4.3] — 2026-10-09
### 新增
- **Telegram 存储后端**:频道/群组存图,直链仍经 PicHost;可选 API 代理
## [1.4.2] — 2026-09-23 ## [1.4.2] — 2026-09-23
### 修复 ### 修复
+21 -4
View File
@@ -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) ![存储管理](/screenshots/storage.png)
@@ -23,11 +23,11 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
## 添加存储后端 ## 添加存储后端
1. 以管理员登录,打开 **存储** → **添加存储**。 1. 以管理员登录,打开 **存储** → **添加存储**。
2. 选择类型:**本地磁盘** 或 **S3 兼容**(含 R2 / COS / OSS 预设)。 2. 选择类型:**本地磁盘**、**S3 兼容**(含 R2 / COS / OSS 预设)或 **Telegram 频道**。
3. 填写连接信息(桶名、Endpoint、Access Key 等)。 3. 填写连接信息(桶名、Endpoint、Access Key,或 Bot Token / Chat ID 等)。
4. 保存后可 **设默认**,新上传将写入该后端。 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 要点 ### Cloudflare R2 要点
@@ -40,6 +40,23 @@ PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、
若仓库 **cloudflare** 分支专用于「仅 R2」部署,也可用环境变量预设,见 [文档首页](./index.md) 分支说明。 若仓库 **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**,与环境变量并存时以管理界面为准管理多后端。 部分部署用环境变量初始化默认 S3 后端(如 `STORAGE_BACKEND=s3`、`S3_ENDPOINT`、`S3_BUCKET`、`S3_ACCESS_KEY`、`S3_SECRET_KEY`、`S3_REGION`)。**UI 中配置的后端信息存于 SQLite**,与环境变量并存时以管理界面为准管理多后端。
+1 -1
View File
@@ -20,7 +20,7 @@ features:
- title: 拖拽 / 粘贴上传 - title: 拖拽 / 粘贴上传
details: 支持点击、拖拽与 Ctrl+V 粘贴;服务端 WebP 压缩与 Referer 防盗链。 details: 支持点击、拖拽与 Ctrl+V 粘贴;服务端 WebP 压缩与 Referer 防盗链。
- title: 多后端存储 - title: 多后端存储
details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);proxy 或 public 直链模式。 details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS)+ Telegram 频道/群组;proxy 或 public 直链。
- title: 多用户与权限 - title: 多用户与权限
details: 管理员与普通用户 RBAC;全局 Token 与个人 Token;图库按归属隔离。 details: 管理员与普通用户 RBAC;全局 Token 与个人 Token;图库按归属隔离。
- title: Docker 零配置 - title: Docker 零配置
+13
View File
@@ -455,6 +455,18 @@
"providerOss": "Alibaba OSS", "providerOss": "Alibaba OSS",
"providerAws": "AWS S3", "providerAws": "AWS S3",
"providerCustom": "Custom", "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", "endpoint": "Endpoint",
"region": "Region", "region": "Region",
"bucket": "Bucket", "bucket": "Bucket",
@@ -516,6 +528,7 @@
"supportedR2Desc": "Highly available, low-cost object storage", "supportedR2Desc": "Highly available, low-cost object storage",
"supportedS3Title": "S3 compatible", "supportedS3Title": "S3 compatible",
"supportedS3Desc": "Storage services compatible with the Amazon S3 API", "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", "footerTip": "The default backend receives all new uploads",
"statTotal": "Total", "statTotal": "Total",
"statUsed": "Used", "statUsed": "Used",
+13
View File
@@ -455,6 +455,18 @@
"providerOss": "阿里云 OSS", "providerOss": "阿里云 OSS",
"providerAws": "AWS S3", "providerAws": "AWS S3",
"providerCustom": "自定义", "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", "endpoint": "Endpoint",
"region": "Region", "region": "Region",
"bucket": "Bucket", "bucket": "Bucket",
@@ -516,6 +528,7 @@
"supportedR2Desc": "高可用、低成本的对象存储", "supportedR2Desc": "高可用、低成本的对象存储",
"supportedS3Title": "S3 兼容", "supportedS3Title": "S3 兼容",
"supportedS3Desc": "兼容 Amazon S3 协议的存储服务", "supportedS3Desc": "兼容 Amazon S3 协议的存储服务",
"supportedTelegramDesc": "用 Telegram 频道/群组存图,适合轻量与无限容量场景",
"footerTip": "默认存储后端将自动接收所有新上传的图片", "footerTip": "默认存储后端将自动接收所有新上传的图片",
"statTotal": "总量", "statTotal": "总量",
"statUsed": "已用", "statUsed": "已用",
+7
View File
@@ -50,6 +50,13 @@ export default defineNuxtConfig({
nitro: { nitro: {
preset: 'node-server', preset: 'node-server',
publicAssets: [
{
baseURL: '/cap',
dir: join(rootDir, 'node_modules/@cap.js/wasm/browser'),
maxAge: 60 * 60 * 24 * 30
}
],
scheduledTasks: { scheduledTasks: {
'0 3 * * *': ['auto-delete'] '0 3 * * *': ['auto-delete']
} }
+24 -7
View File
@@ -1,24 +1,26 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.4.2", "version": "1.4.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pichost", "name": "pichost",
"version": "1.4.2", "version": "1.4.4",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.876.0", "@aws-sdk/client-s3": "^3.876.0",
"@cap.js/wasm": "^0.0.8",
"@iconify-json/lucide": "^1.2.114", "@iconify-json/lucide": "^1.2.114",
"@nuxt/ui": "^4.9.0", "@nuxt/ui": "^4.9.0",
"@nuxtjs/i18n": "^10.6.0", "@nuxtjs/i18n": "^10.6.0",
"cap-widget": "^0.1.57", "cap-widget": "^0.1.58",
"nuxt": "^4.4.8", "nuxt": "^4.4.8",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"tar": "^7.5.22" "tar": "^7.5.22",
"undici": "^7.30.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint": "^1.16.0", "@nuxt/eslint": "^1.16.0",
@@ -1047,6 +1049,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@cap.js/wasm": {
"version": "0.0.8",
"resolved": "https://registry.npmmirror.com/@cap.js/wasm/-/wasm-0.0.8.tgz",
"integrity": "sha512-rX+4/v2sz4GKLnygXHOS89O7c4GM2qNQcDJP6Y2cyGwoKjg83bsfWHt1TWr9GBzHpFADvju5fvLWYds21nS8sA==",
"license": "Apache-2.0"
},
"node_modules/@capsizecss/unpack": { "node_modules/@capsizecss/unpack": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmmirror.com/@capsizecss/unpack/-/unpack-4.0.1.tgz", "resolved": "https://registry.npmmirror.com/@capsizecss/unpack/-/unpack-4.0.1.tgz",
@@ -10729,9 +10737,9 @@
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cap-widget": { "node_modules/cap-widget": {
"version": "0.1.57", "version": "0.1.58",
"resolved": "https://registry.npmmirror.com/cap-widget/-/cap-widget-0.1.57.tgz", "resolved": "https://registry.npmmirror.com/cap-widget/-/cap-widget-0.1.58.tgz",
"integrity": "sha512-SyIK5XgYIFSDgH683M9KpfBmDSDB0ZfPZQBxL6MggNrjjnbIQYpbsNpj0S7PC/wXp7oJgAuBD2vtJnN9jJghBg==", "integrity": "sha512-7iS/Q++AaiVvWR8xvMSxRwGWY+fV1ZADJNwPzLk4P1gibCmCbS5G6MGOtoUjsj9OxI9+0nkewW6gRsOGDCOZZA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/ccount": { "node_modules/ccount": {
@@ -19181,6 +19189,15 @@
"node": ">=18.12.0" "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": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
+5 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.4.2", "version": "1.4.4",
"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.", "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", "homepage": "https://github.com/O96u/PicHost#readme",
"repository": { "repository": {
@@ -46,14 +46,16 @@
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.876.0", "@aws-sdk/client-s3": "^3.876.0",
"@cap.js/wasm": "^0.0.8",
"@iconify-json/lucide": "^1.2.114", "@iconify-json/lucide": "^1.2.114",
"@nuxt/ui": "^4.9.0", "@nuxt/ui": "^4.9.0",
"@nuxtjs/i18n": "^10.6.0", "@nuxtjs/i18n": "^10.6.0",
"cap-widget": "^0.1.57", "cap-widget": "^0.1.58",
"nuxt": "^4.4.8", "nuxt": "^4.4.8",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.3.1",
"tar": "^7.5.22" "tar": "^7.5.22",
"undici": "^7.30.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxt/eslint": "^1.16.0", "@nuxt/eslint": "^1.16.0",
+34 -2
View File
@@ -9,6 +9,11 @@ import {
type StorageBackendPatch type StorageBackendPatch
} from '../../../utils/storage-backends' } from '../../../utils/storage-backends'
import { parseS3Config, validateS3Config } from '../../../utils/storage/s3' import { parseS3Config, validateS3Config } from '../../../utils/storage/s3'
import {
parseTelegramConfig,
validateTelegramConfig,
validateApiProxyUrl
} from '../../../utils/storage/telegram'
import { parseQuotaBytes } from '../../../utils/storage-capacity' import { parseQuotaBytes } from '../../../utils/storage-capacity'
import type { ServingMode } from '../../../utils/storage/types' import type { ServingMode } from '../../../utils/storage/types'
@@ -20,10 +25,13 @@ interface StoragePatchBody {
bucket?: string bucket?: string
prefix?: string prefix?: string
forcePathStyle?: boolean forcePathStyle?: boolean
chatId?: string
apiProxyUrl?: string
} }
secrets?: { secrets?: {
accessKeyId?: string accessKeyId?: string
secretAccessKey?: string secretAccessKey?: string
botToken?: string
} }
servingMode?: ServingMode servingMode?: ServingMode
publicUrl?: string publicUrl?: string
@@ -63,6 +71,26 @@ export default defineEventHandler(async (event) => {
patch.name = body.name.trim() || row.name 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') { if (body.config !== undefined && row.type === 's3') {
const current = parseS3Config(row.config_json) ?? { const current = parseS3Config(row.config_json) ?? {
endpoint: '', endpoint: '',
@@ -87,7 +115,11 @@ export default defineEventHandler(async (event) => {
patch.secrets = body.secrets 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') { if (body.servingMode !== 'proxy' && body.servingMode !== 'public') {
createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400) createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400)
} }
@@ -99,7 +131,7 @@ export default defineEventHandler(async (event) => {
} }
if (body.quotaBytes !== undefined) { if (body.quotaBytes !== undefined) {
if (row.type === 's3') { if (row.type === 's3' || row.type === 'telegram') {
const quotaBytes = parseQuotaBytes(body.quotaBytes) const quotaBytes = parseQuotaBytes(body.quotaBytes)
if (!quotaBytes) { if (!quotaBytes) {
createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400) createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400)
+53
View File
@@ -2,13 +2,19 @@ import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error' import { createApiError } from '../../../utils/api-error'
import { import {
insertStorageBackend, insertStorageBackend,
insertTelegramBackend,
isStorageEnvConfigured isStorageEnvConfigured
} from '../../../utils/storage-backends' } from '../../../utils/storage-backends'
import { validateS3Config } from '../../../utils/storage/s3' import { validateS3Config } from '../../../utils/storage/s3'
import {
validateTelegramConfig,
validateApiProxyUrl
} from '../../../utils/storage/telegram'
import { parseQuotaBytes } from '../../../utils/storage-capacity' import { parseQuotaBytes } from '../../../utils/storage-capacity'
import type { ServingMode } from '../../../utils/storage/types' import type { ServingMode } from '../../../utils/storage/types'
interface StorageCreateBody { interface StorageCreateBody {
type?: 's3' | 'telegram'
name?: string name?: string
provider?: string provider?: string
config?: { config?: {
@@ -17,10 +23,13 @@ interface StorageCreateBody {
bucket?: string bucket?: string
prefix?: string prefix?: string
forcePathStyle?: boolean forcePathStyle?: boolean
chatId?: string
apiProxyUrl?: string
} }
secrets?: { secrets?: {
accessKeyId?: string accessKeyId?: string
secretAccessKey?: string secretAccessKey?: string
botToken?: string
} }
servingMode?: ServingMode servingMode?: ServingMode
publicUrl?: string publicUrl?: string
@@ -40,6 +49,50 @@ export default defineEventHandler(async (event) => {
(): StorageCreateBody => ({}) (): 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 name = body.name?.trim() || 'S3 兼容存储'
const config = { const config = {
endpoint: body.config?.endpoint?.trim() ?? '', endpoint: body.config?.endpoint?.trim() ?? '',
+5 -1
View File
@@ -106,7 +106,11 @@ export default defineEventHandler(async (event) => {
? { ? {
id: row.backend_id, id: row.backend_id,
name: row.backend_name ?? 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, : null,
ipAddress: row.ip_address, ipAddress: row.ip_address,
+22
View File
@@ -0,0 +1,22 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll } from 'vitest'
import { closeDb } from '../utils/db'
const workerId = process.env.VITEST_POOL_ID ?? String(process.pid)
const dataDir = mkdtempSync(join(tmpdir(), `pichost-test-${workerId}-`))
process.env.DATA_DIR = dataDir
beforeAll(() => {
closeDb()
})
afterAll(() => {
closeDb()
try {
rmSync(dataDir, { recursive: true, force: true })
} catch {
// Windows may still hold the sqlite file for a moment after close
}
})
+6 -1
View File
@@ -7,6 +7,7 @@ import {
listStorageBackendRows listStorageBackendRows
} from '../storage-backends' } from '../storage-backends'
import { parseS3Config } from '../storage/s3' import { parseS3Config } from '../storage/s3'
import { parseTelegramConfig } from '../storage/telegram'
import { createImageStream } from '../storage' import { createImageStream } from '../storage'
import { copyDatabaseSnapshot } from './db-snapshot' import { copyDatabaseSnapshot } from './db-snapshot'
import { markJobDone, markJobFailed, updateBackupJob } from './jobs' import { markJobDone, markJobFailed, updateBackupJob } from './jobs'
@@ -27,7 +28,11 @@ function buildStorageBackendsMeta(): StorageBackendMetaExport[] {
id: row.id, id: row.id,
name: row.name, name: row.name,
type: row.type, 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, servingMode: row.serving_mode,
publicUrl: row.public_url, publicUrl: row.public_url,
quotaBytes: row.quota_bytes, quotaBytes: row.quota_bytes,
+2 -1
View File
@@ -131,7 +131,8 @@ describe('shouldBlockSiteHostImageRequest', () => {
siteHost, siteHost,
requestHost: siteHost, requestHost: siteHost,
method: 'GET', method: 'GET',
pathname: '/' pathname: '/',
hideFolder: false
})).toBe(false) })).toBe(false)
}) })
+9 -5
View File
@@ -12,8 +12,8 @@ import { generateImageKey } from './image-key'
import { buildImageItem, sanitizeOriginalName } from './image-response' import { buildImageItem, sanitizeOriginalName } from './image-response'
import { getStorageLayout, getWebpQuality } from './env' import { getStorageLayout, getWebpQuality } from './env'
import { import {
formatMaxFileSizeMessage, formatEffectiveMaxFileSizeMessage,
getMaxFileSizeBytes, getEffectiveMaxFileSizeBytes,
isMimeTypeAllowed, isMimeTypeAllowed,
isPreserveOriginalUpload isPreserveOriginalUpload
} from './upload-policy' } from './upload-policy'
@@ -73,13 +73,13 @@ export async function processSingleImageUpload(
const originalName = sanitizeOriginalName(input.filename ?? 'image') const originalName = sanitizeOriginalName(input.filename ?? 'image')
const bytes = input.bytes const bytes = input.bytes
if (bytes.byteLength > getMaxFileSizeBytes()) { if (bytes.byteLength > getEffectiveMaxFileSizeBytes()) {
return { return {
error: { error: {
name: originalName, name: originalName,
error: { error: {
code: 'FILE_TOO_LARGE', code: 'FILE_TOO_LARGE',
message: formatMaxFileSizeMessage() message: formatEffectiveMaxFileSizeMessage()
} }
} }
} }
@@ -144,12 +144,16 @@ export async function processSingleImageUpload(
name: originalName, name: originalName,
source: input.source ?? 'web' source: input.source ?? 'web'
}) })
const detail = error instanceof Error ? error.message.trim() : ''
const message = detail.startsWith('Telegram') || detail.includes('群组')
? detail
: '上传失败,请稍后重试'
return { return {
error: { error: {
name: originalName, name: originalName,
error: { error: {
code: 'UPLOAD_FAILED', code: 'UPLOAD_FAILED',
message: '上传失败,请稍后重试' message
} }
} }
} }
+155 -5
View File
@@ -1,28 +1,99 @@
import type { DatabaseSync } from 'node:sqlite' import type { DatabaseSync } from 'node:sqlite'
import { getDb } from './db' import { getDb } from './db'
import { LocalStorageBackend } from './storage/local' import { LocalStorageBackend } from './storage/local'
import { deleteStorageObjectRefsForBackend } from './storage/storage-object-refs'
import { import {
parseS3Config, parseS3Config,
parseS3Secrets, parseS3Secrets,
S3StorageBackend S3StorageBackend
} from './storage/s3' } from './storage/s3'
import {
parseTelegramConfig,
parseTelegramSecrets,
TelegramStorageBackend
} from './storage/telegram'
import type { import type {
S3BackendConfig, S3BackendConfig,
ServingMode, ServingMode,
StorageBackendInfo, StorageBackendInfo,
StorageBackendRow, StorageBackendRow,
StorageBackendType StorageBackendType,
TelegramBackendConfig,
TelegramBackendSecrets
} from './storage/types' } from './storage/types'
export const LOCAL_BACKEND_ID = 'local' export const LOCAL_BACKEND_ID = 'local'
export const S3_BACKEND_ID = 's3-primary' 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 { function migrateStorageTables(database: DatabaseSync): void {
database.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS storage_backends ( CREATE TABLE IF NOT EXISTS storage_backends (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, 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 '{}', config_json TEXT NOT NULL DEFAULT '{}',
secret_json TEXT NOT NULL DEFAULT '{}', secret_json TEXT NOT NULL DEFAULT '{}',
serving_mode TEXT NOT NULL DEFAULT 'proxy' CHECK(serving_mode IN ('proxy', 'public')), 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_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_user_id ON images(user_id);
CREATE INDEX IF NOT EXISTS idx_images_folder ON images(folder); 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 { export function ensureStorageSchema(): void {
@@ -109,6 +188,8 @@ function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendI
} { } {
const config = row.type === 's3' const config = row.type === 's3'
? (parseS3Config(row.config_json) ?? {}) ? (parseS3Config(row.config_json) ?? {})
: row.type === 'telegram'
? (parseTelegramConfig(row.config_json) ?? {})
: {} : {}
let secretsMasked: Record<string, string> = {} let secretsMasked: Record<string, string> = {}
if (row.type === 's3') { if (row.type === 's3') {
@@ -121,6 +202,11 @@ function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendI
? maskSecret(secrets.secretAccessKey) ? maskSecret(secrets.secretAccessKey)
: '' : ''
} }
} else if (row.type === 'telegram') {
const secrets = parseTelegramSecrets(row.secret_json)
secretsMasked = {
botToken: secrets?.botToken ? maskSecret(secrets.botToken) : ''
}
} }
return { return {
@@ -213,7 +299,11 @@ export function setDefaultBackend(id: string): void {
export interface StorageBackendPatch { export interface StorageBackendPatch {
name?: string name?: string
config?: Record<string, unknown> config?: Record<string, unknown>
secrets?: { accessKeyId?: string, secretAccessKey?: string } secrets?: {
accessKeyId?: string
secretAccessKey?: string
botToken?: string
}
servingMode?: ServingMode servingMode?: ServingMode
publicUrl?: string publicUrl?: string
quotaBytes?: number | null quotaBytes?: number | null
@@ -234,6 +324,8 @@ export function updateStorageBackend(id: string, patch: StorageBackendPatch): vo
if (patch.config !== undefined) { if (patch.config !== undefined) {
const current = row.type === 's3' const current = row.type === 's3'
? (parseS3Config(row.config_json) ?? {}) ? (parseS3Config(row.config_json) ?? {})
: row.type === 'telegram'
? (parseTelegramConfig(row.config_json) ?? { chatId: '' })
: {} : {}
configJson = JSON.stringify({ ...current, ...patch.config }) configJson = JSON.stringify({ ...current, ...patch.config })
} }
@@ -254,7 +346,20 @@ export function updateStorageBackend(id: string, patch: StorageBackendPatch): vo
secretJson = JSON.stringify(next) 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 publicUrl = patch.publicUrl ?? row.public_url
const quotaBytes = patch.quotaBytes !== undefined const quotaBytes = patch.quotaBytes !== undefined
? patch.quotaBytes ? 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 config = parseS3Config(row.config_json)
const secrets = parseS3Secrets(row.secret_json) const secrets = parseS3Secrets(row.secret_json)
if (!config || !secrets) { if (!config || !secrets) {
@@ -368,7 +482,7 @@ function getNextSortOrder(): number {
return row.max_order + 1 return row.max_order + 1
} }
export type ObjectStorageIdPrefix = 'r2' | 'cos' | 'oss' | 's3' export type ObjectStorageIdPrefix = 'r2' | 'cos' | 'oss' | 's3' | 'tg'
/** 根据表单预设或 endpoint 推断新建后端的 id 前缀 */ /** 根据表单预设或 endpoint 推断新建后端的 id 前缀 */
export function resolveObjectStorageIdPrefix( export function resolveObjectStorageIdPrefix(
@@ -438,6 +552,41 @@ export function insertStorageBackend(input: {
return id 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 { export function deleteStorageBackend(id: string): void {
if (id === LOCAL_BACKEND_ID) { if (id === LOCAL_BACKEND_ID) {
throw new Error('Cannot delete local backend') 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') throw new Error('Cannot delete the default backend')
} }
deleteStorageObjectRefsForBackend(id)
getDb().prepare('DELETE FROM storage_backends WHERE id = ?').run(id) getDb().prepare('DELETE FROM storage_backends WHERE id = ?').run(id)
} }
+2 -2
View File
@@ -51,11 +51,11 @@ function buildQuotaCapacity(quotaBytes: number, indexBytes: number): StorageCapa
export async function buildBackendCapacity( export async function buildBackendCapacity(
backendId: string, backendId: string,
backendType: 'local' | 's3', backendType: 'local' | 's3' | 'telegram',
indexBytes: number, indexBytes: number,
quotaBytes: number | null quotaBytes: number | null
): Promise<StorageCapacity> { ): Promise<StorageCapacity> {
if (backendType === 's3') { if (backendType === 's3' || backendType === 'telegram') {
if (quotaBytes && quotaBytes > 0) { if (quotaBytes && quotaBytes > 0) {
return buildQuotaCapacity(quotaBytes, indexBytes) return buildQuotaCapacity(quotaBytes, indexBytes)
} }
+7
View File
@@ -1,3 +1,10 @@
export { getActiveBackend, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver' export { getActiveBackend, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver'
export { LocalStorageBackend } from './local' export { LocalStorageBackend } from './local'
export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3' export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3'
export {
TelegramStorageBackend,
parseTelegramConfig,
parseTelegramSecrets,
validateTelegramConfig,
TELEGRAM_MAX_FILE_BYTES
} from './telegram'
@@ -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)
}
+322
View File
@@ -0,0 +1,322 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'
import { closeDb } from '../db'
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'
const tempDir = mkdtempSync(join(tmpdir(), 'pichost-tg-'))
const previousDataDir = process.env.DATA_DIR
beforeAll(() => {
process.env.DATA_DIR = tempDir
closeDb()
})
afterAll(() => {
closeDb()
if (previousDataDir === undefined) {
delete process.env.DATA_DIR
} else {
process.env.DATA_DIR = previousDataDir
}
rmSync(tempDir, { recursive: true, force: true })
})
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(() => {
try {
deleteStorageObjectRef(backendId, key)
} catch {
// schema setup may have failed before the ref table existed
}
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]))
})
})
+510
View File
@@ -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<TelegramBackendConfig>
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<TelegramBackendSecrets>
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<T> {
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<TelegramFileIdentity>
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<Response> {
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<typeof undici.fetch>[1])
return response as unknown as Response
}
async function callTelegramApi<T>(
botToken: string,
method: string,
init: RequestInit,
apiProxyUrl?: string,
attempt = 0
): Promise<T> {
const response = await telegramFetch(apiUrl(botToken, method), init, apiProxyUrl)
const body = await response.json() as TelegramApiResponse<T>
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<void> {
if (newChatId === this.chatId) return
this.chatId = newChatId
const { updateStorageBackend } = await import('../storage-backends')
updateStorageBackend(this.id, { config: { chatId: newChatId } })
}
private async withChatIdMigrationRetry<T>(
fn: (chatId: string) => Promise<T>
): Promise<T> {
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<void> {
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<TelegramSentMessage>(
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<StoredImage | null> {
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<void> {
await callTelegramApi<boolean>(
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<void> {
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<Readable> {
const ref = getStorageObjectRef(this.id, key)
if (!ref?.fileId) {
throw new Error('Telegram 文件引用不存在')
}
const file = await callTelegramApi<TelegramFile>(
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 }
}
}
+17 -2
View File
@@ -28,7 +28,7 @@ export interface FolderStorageStat {
bytes: number bytes: number
} }
export type StorageBackendType = 'local' | 's3' export type StorageBackendType = 'local' | 's3' | 'telegram'
export type ServingMode = 'proxy' | 'public' export type ServingMode = 'proxy' | 'public'
export interface S3BackendConfig { export interface S3BackendConfig {
@@ -44,6 +44,21 @@ export interface S3BackendSecrets {
secretAccessKey: string 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 { export interface StorageBackendRow {
id: string id: string
name: string name: string
@@ -62,7 +77,7 @@ export interface StorageBackendInfo {
id: string id: string
name: string name: string
type: StorageBackendType type: StorageBackendType
config: S3BackendConfig | Record<string, never> config: S3BackendConfig | TelegramBackendConfig | Record<string, never>
servingMode: ServingMode servingMode: ServingMode
publicUrl: string publicUrl: string
quotaBytes: number | null quotaBytes: number | null
+24
View File
@@ -3,6 +3,8 @@ import {
type AllowedMimeType type AllowedMimeType
} from './constants' } from './constants'
import { getSetting, setSetting } from './db' 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_UPLOAD_MAX_FILE_SIZE_MB = 'upload_max_file_size_mb'
export const SETTINGS_ALLOWED_MIME_TYPES = 'allowed_mime_types' export const SETTINGS_ALLOWED_MIME_TYPES = 'allowed_mime_types'
@@ -180,6 +182,15 @@ export function getMaxFileSizeBytes(): number {
return getUploadMaxFileSizeMb() * 1024 * 1024 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[] { export function getAllowedMimeTypesList(): AllowedMimeType[] {
const dbRaw = getSetting(SETTINGS_ALLOWED_MIME_TYPES) const dbRaw = getSetting(SETTINGS_ALLOWED_MIME_TYPES)
if (dbRaw !== null) { if (dbRaw !== null) {
@@ -279,6 +290,19 @@ export function formatMaxFileSizeMessage(): string {
return `图片大小不能超过 ${getUploadMaxFileSizeMb()} MB` 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() { export function getUploadPolicySettingsPayload() {
return { return {
uploadMaxFileSizeMb: getUploadMaxFileSizeMb(), uploadMaxFileSizeMb: getUploadMaxFileSizeMb(),
+3 -1
View File
@@ -3,6 +3,8 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
include: ['server/**/*.test.ts'] include: ['server/**/*.test.ts'],
pool: 'forks',
setupFiles: ['./server/test/setup.ts']
} }
}) })