5 Commits
Author SHA1 Message Date
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
O96u 4ed4692f5e release: v1.4.2
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
Fix batch tagging and upload token auth; single API token and tagNames-only upload; tag management sort and responsive layout.
2026-09-23 15:38:46 +08:00
O96u 6cc4f27f7d release: v1.4.1 — 上传策略设置、保留原图与上传 tagNames
GitHub Release / release (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
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
管理员可在设置页配置上传大小、格式、限流与保留原图;上传 API 支持按标签名称打标。
2026-09-16 10:03:05 +08:00
O96u d0eb364645 fix: host-isolation 单测显式传入 hideFolder 避免 CI 锁库
并行跑测时不再通过 isHideFolderInUrl 打开本地 SQLite。
2026-09-09 15:08:27 +08:00
O96u 91d71a1b40 fix: 修复 CI 活动日志冒烟测试在空数据库下失败
listActivityLogs 会 JOIN storage_backends;CI 无 data/ 目录时需在查询前初始化存储 schema。
2026-09-09 14:57:11 +08:00
63 changed files with 3425 additions and 396 deletions

No files matched your search

+10
View File
@@ -30,6 +30,16 @@
# 服务端 WebP 压缩质量 1-100(默认 80;也可在设置页配置) # 服务端 WebP 压缩质量 1-100(默认 80;也可在设置页配置)
# WEBP_QUALITY=80 # WEBP_QUALITY=80
# 上传限制(也可在设置 → 访问控制配置;环境变量优先级更高)
# UPLOAD_MAX_FILE_SIZE_MB=10
# ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon
# UPLOAD_RATE_IP_MAX=60
# UPLOAD_RATE_TOKEN_MAX=120
# UPLOAD_RATE_WINDOW_MINUTES=15
# LOGIN_RATE_MAX=10
# LOGIN_RATE_WINDOW_MINUTES=15
# PRESERVE_ORIGINAL_UPLOAD=false
# 自动删除超过 N 天的图片(0 表示关闭;也可在设置页配置) # 自动删除超过 N 天的图片(0 表示关闭;也可在设置页配置)
# AUTO_DELETE_DAYS=0 # AUTO_DELETE_DAYS=0
+40
View File
@@ -4,6 +4,46 @@
## [Unreleased] ## [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
### 修复
- 图库批量打标:管理员/跨归属场景下标签解析与失败提示
- 上传鉴权:`Auth-Token` 与表单 `token` 优先于会话;兼容全局 `API_UPLOAD_TOKEN`(上传归属首位管理员)
### 改进
- **API 页**:每账号单一 API Token,界面与文档统一
- **上传 API**:仅支持 `tagNames`(按名创建或复用标签);Twikoo 兼容路由同步
- **标签管理**:按图片数/最近使用排序;桌面列布局;窄屏卡片列表
- **API 文档**:参数表列宽,避免 form-data 列异常换行
## [1.4.1] - 2026-09-16
### 新增
- **上传策略(设置 → 基础)**:单文件大小、允许格式、上传/登录限流、保留原图(关闭服务端 WebP 转换);支持 `.env` 覆盖
- **上传 API**:可选 `tagNames`(名称不存在则创建,存在则复用);与 `tagIds` 可并用
### 改进
- 登录接口接入可配置的登录限流
- 设置页将全站上传策略并入「上传偏好 / 图片处理」;访问控制保留登录限流
- API 文档补充 `tagIds` / `tagNames` 参数说明
## [1.4.0] - 2026-09-09 ## [1.4.0] - 2026-09-09
### 新增 ### 新增
+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 |
+449 -130
View File
@@ -8,12 +8,15 @@ import {
TAG_COLOR_EDIT_CANDIDATE_COUNT TAG_COLOR_EDIT_CANDIDATE_COUNT
} from '~/utils/tag-colors' } from '~/utils/tag-colors'
const props = withDefaults(defineProps<{ const props = withDefaults(
defineProps<{
tags: ImageTag[] tags: ImageTag[]
embedded?: boolean embedded?: boolean
}>(), { }>(),
{
embedded: false embedded: false
}) }
)
const emit = defineEmits<{ const emit = defineEmits<{
changed: [] changed: []
@@ -27,7 +30,9 @@ const { createTag, updateTag, deleteTag, mergeTags } = useTags()
const creating = ref(false) const creating = ref(false)
const newName = ref('') const newName = ref('')
const newColorCandidates = ref<string[]>(pickTagColorCandidates([])) const newColorCandidates = ref<string[]>(pickTagColorCandidates([]))
const newColor = ref(pickDefaultTagColorFromCandidates(newColorCandidates.value, [])) const newColor = ref(
pickDefaultTagColorFromCandidates(newColorCandidates.value, [])
)
const search = ref('') const search = ref('')
const statusFilter = ref<'all' | 'used' | 'unused'>('all') const statusFilter = ref<'all' | 'used' | 'unused'>('all')
const selectedIds = ref<Set<number>>(new Set()) const selectedIds = ref<Set<number>>(new Set())
@@ -47,6 +52,10 @@ const saving = ref(false)
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
type TagSortKey = 'imageCount' | 'lastUsedAt'
const sortKey = ref<TagSortKey>('imageCount')
const sortDir = ref<'asc' | 'desc'>('desc')
const stats = computed(() => { const stats = computed(() => {
const now = Date.now() const now = Date.now()
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000 const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000
@@ -71,15 +80,31 @@ const stats = computed(() => {
const filteredTags = computed(() => { const filteredTags = computed(() => {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
return props.tags.filter((tag) => { const list = props.tags.filter((tag) => {
if (statusFilter.value === 'used' && (tag.imageCount ?? 0) === 0) return false if (statusFilter.value === 'used' && (tag.imageCount ?? 0) === 0)
if (statusFilter.value === 'unused' && (tag.imageCount ?? 0) > 0) return false return false
if (statusFilter.value === 'unused' && (tag.imageCount ?? 0) > 0)
return false
if (q && !tag.name.toLowerCase().includes(q)) return false if (q && !tag.name.toLowerCase().includes(q)) return false
return true return true
}).sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0) || a.name.localeCompare(b.name))
}) })
const totalPages = computed(() => Math.max(1, Math.ceil(filteredTags.value.length / pageSize.value))) const dir = sortDir.value === 'asc' ? 1 : -1
return list.sort((a, b) => {
let cmp = 0
if (sortKey.value === 'imageCount') {
cmp = (a.imageCount ?? 0) - (b.imageCount ?? 0)
} else {
cmp = lastUsedSortTime(a) - lastUsedSortTime(b)
}
if (cmp !== 0) return cmp * dir
return a.name.localeCompare(b.name)
})
})
const totalPages = computed(() =>
Math.max(1, Math.ceil(filteredTags.value.length / pageSize.value))
)
const paginatedTags = computed(() => { const paginatedTags = computed(() => {
const start = (page.value - 1) * pageSize.value const start = (page.value - 1) * pageSize.value
@@ -96,7 +121,8 @@ watch(filteredTags, (list) => {
} }
}) })
const selectedList = computed(() => const selectedList = computed(
() =>
[...selectedIds.value] [...selectedIds.value]
.map(id => props.tags.find(tag => tag.id === id)) .map(id => props.tags.find(tag => tag.id === id))
.filter(Boolean) as ImageTag[] .filter(Boolean) as ImageTag[]
@@ -104,12 +130,14 @@ const selectedList = computed(() =>
const selectedCount = computed(() => selectedIds.value.size) const selectedCount = computed(() => selectedIds.value.size)
const allPageSelected = computed(() => const allPageSelected = computed(
() =>
paginatedTags.value.length > 0 paginatedTags.value.length > 0
&& paginatedTags.value.every(tag => selectedIds.value.has(tag.id)) && paginatedTags.value.every(tag => selectedIds.value.has(tag.id))
) )
const mergeSourceTags = computed(() => const mergeSourceTags = computed(
() =>
mergeSourceIds.value mergeSourceIds.value
.map(id => props.tags.find(tag => tag.id === id)) .map(id => props.tags.find(tag => tag.id === id))
.filter(Boolean) as ImageTag[] .filter(Boolean) as ImageTag[]
@@ -119,7 +147,11 @@ const mergeTargetCandidates = computed(() => {
const sourceSet = new Set(mergeSourceIds.value) const sourceSet = new Set(mergeSourceIds.value)
return props.tags return props.tags
.filter(tag => !sourceSet.has(tag.id)) .filter(tag => !sourceSet.has(tag.id))
.sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0) || a.name.localeCompare(b.name)) .sort(
(a, b) =>
(b.imageCount ?? 0) - (a.imageCount ?? 0)
|| a.name.localeCompare(b.name)
)
}) })
const mergeTagsToDelete = computed(() => { const mergeTagsToDelete = computed(() => {
@@ -127,8 +159,8 @@ const mergeTagsToDelete = computed(() => {
return mergeSourceTags.value.filter(tag => tag.id !== mergeTargetId.value) return mergeSourceTags.value.filter(tag => tag.id !== mergeTargetId.value)
}) })
const mergeKeepTag = computed(() => const mergeKeepTag = computed(
props.tags.find(tag => tag.id === mergeTargetId.value) ?? null () => props.tags.find(tag => tag.id === mergeTargetId.value) ?? null
) )
const statusItems = computed(() => [ const statusItems = computed(() => [
@@ -137,28 +169,71 @@ const statusItems = computed(() => [
{ label: t('tags.filterStatusUnused'), value: 'unused' } { label: t('tags.filterStatusUnused'), value: 'unused' }
]) ])
const statValueClass = computed(() => function tagSortTime(iso: string | null | undefined): number {
'mt-2 text-2xl font-semibold tabular-nums text-highlighted' if (!iso) return 0
const time = new Date(iso).getTime()
return Number.isNaN(time) ? 0 : time
}
function lastUsedSortTime(tag: ImageTag): number {
if ((tag.imageCount ?? 0) === 0) return 0
return tagSortTime(tag.lastUsedAt)
}
function toggleSort(key: TagSortKey) {
if (sortKey.value === key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortDir.value = 'desc'
}
page.value = 1
}
function sortIcon(key: TagSortKey): string {
if (sortKey.value !== key) return 'i-lucide-arrow-up-down'
return sortDir.value === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
}
function sortIconClass(key: TagSortKey): string {
const base = 'size-3 shrink-0'
return sortKey.value === key
? `${base} opacity-70`
: `${base} opacity-40`
}
const statValueClass = computed(
() => 'mt-2 text-2xl font-semibold tabular-nums text-highlighted'
) )
const tableTextClass = computed(() => 'text-sm') const tableTextClass = computed(() => 'text-sm')
const tagRowClass = 'flex items-center gap-x-3 px-3'
const tagNameColClass = 'min-w-0 flex-1 basis-0 pr-8 sm:pr-12'
const tagRightGroupClass
= 'ml-auto flex shrink-0 items-center gap-x-8 sm:gap-x-10'
const tagCountColClass
= 'w-44 shrink-0 text-left tabular-nums whitespace-nowrap sm:w-48'
const tagLastColClass
= 'w-[10.5rem] shrink-0 whitespace-nowrap text-xs text-muted'
const tagActionsColClass = 'w-[9.5rem] shrink-0'
function formatLastUsed(tag: ImageTag) { function formatLastUsed(tag: ImageTag) {
if (!tag.lastUsedAt || (tag.imageCount ?? 0) === 0) { if (!tag.lastUsedAt || (tag.imageCount ?? 0) === 0) {
return t('tags.neverUsed') return t('tags.neverUsed')
} }
const date = new Date(tag.lastUsedAt) try {
if (Number.isNaN(date.getTime())) { return new Date(tag.lastUsedAt).toLocaleString(locale.value, {
return t('tags.neverUsed')
}
return date.toLocaleString(locale.value, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false hour12: false
}) })
} catch {
return t('tags.neverUsed')
}
} }
function toggleSelect(id: number) { function toggleSelect(id: number) {
@@ -191,7 +266,10 @@ function usedColorsExcept(tagId?: number) {
function refreshNewTagColor() { function refreshNewTagColor() {
const used = usedColorsExcept() const used = usedColorsExcept()
newColorCandidates.value = pickTagColorCandidates(used) newColorCandidates.value = pickTagColorCandidates(used)
newColor.value = pickDefaultTagColorFromCandidates(newColorCandidates.value, used) newColor.value = pickDefaultTagColorFromCandidates(
newColorCandidates.value,
used
)
} }
function toggleCreating() { function toggleCreating() {
@@ -303,7 +381,10 @@ async function confirmDelete() {
selectedIds.value.delete(tag.id) selectedIds.value.delete(tag.id)
selectedIds.value = new Set(selectedIds.value) selectedIds.value = new Set(selectedIds.value)
emit('changed') emit('changed')
toast.add({ title: t('tags.deleted', { name: tag.name }), color: 'success' }) toast.add({
title: t('tags.deleted', { name: tag.name }),
color: 'success'
})
} catch { } catch {
toast.add({ title: t('tags.deleteFailed'), color: 'error' }) toast.add({ title: t('tags.deleteFailed'), color: 'error' })
} finally { } finally {
@@ -318,7 +399,9 @@ function openBatchMerge() {
} }
mergeMode.value = 'batch' mergeMode.value = 'batch'
mergeSourceIds.value = [...selectedIds.value] mergeSourceIds.value = [...selectedIds.value]
const sorted = selectedList.value.sort((a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0)) const sorted = selectedList.value.sort(
(a, b) => (b.imageCount ?? 0) - (a.imageCount ?? 0)
)
mergeTargetId.value = sorted[0]?.id ?? null mergeTargetId.value = sorted[0]?.id ?? null
mergeOpen.value = true mergeOpen.value = true
} }
@@ -335,14 +418,18 @@ function openSingleMerge(tag: ImageTag) {
function requestMergeConfirm() { function requestMergeConfirm() {
if (!mergeTargetId.value) return if (!mergeTargetId.value) return
const sourceIds = mergeSourceIds.value.filter(id => id !== mergeTargetId.value) const sourceIds = mergeSourceIds.value.filter(
id => id !== mergeTargetId.value
)
if (!sourceIds.length) return if (!sourceIds.length) return
mergeConfirmOpen.value = true mergeConfirmOpen.value = true
} }
async function confirmMerge() { async function confirmMerge() {
if (!mergeTargetId.value || saving.value) return if (!mergeTargetId.value || saving.value) return
const sourceIds = mergeSourceIds.value.filter(id => id !== mergeTargetId.value) const sourceIds = mergeSourceIds.value.filter(
id => id !== mergeTargetId.value
)
if (!sourceIds.length) return if (!sourceIds.length) return
saving.value = true saving.value = true
try { try {
@@ -365,13 +452,15 @@ async function confirmMerge() {
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="rounded-xl border border-default bg-elevated p-4"> <div class="rounded-xl border border-default bg-elevated p-4">
<div class="flex items-center gap-2 text-xs text-muted"> <div class="flex items-center gap-2 text-xs text-muted">
<span class="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary"> <span
class="flex size-7 items-center justify-center rounded-lg bg-primary/10 text-primary"
>
<UIcon <UIcon
name="i-lucide-tags" name="i-lucide-tags"
class="size-4" class="size-4"
/> />
</span> </span>
{{ t('tags.statTotal') }} {{ t("tags.statTotal") }}
</div> </div>
<p :class="statValueClass"> <p :class="statValueClass">
{{ stats.total }} {{ stats.total }}
@@ -379,13 +468,15 @@ async function confirmMerge() {
</div> </div>
<div class="rounded-xl border border-default bg-elevated p-4"> <div class="rounded-xl border border-default bg-elevated p-4">
<div class="flex items-center gap-2 text-xs text-muted"> <div class="flex items-center gap-2 text-xs text-muted">
<span class="flex size-7 items-center justify-center rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400"> <span
class="flex size-7 items-center justify-center rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400"
>
<UIcon <UIcon
name="i-lucide-image" name="i-lucide-image"
class="size-4" class="size-4"
/> />
</span> </span>
{{ t('tags.statUsed') }} {{ t("tags.statUsed") }}
</div> </div>
<p :class="statValueClass"> <p :class="statValueClass">
{{ stats.used }} {{ stats.used }}
@@ -393,13 +484,15 @@ async function confirmMerge() {
</div> </div>
<div class="rounded-xl border border-default bg-elevated p-4"> <div class="rounded-xl border border-default bg-elevated p-4">
<div class="flex items-center gap-2 text-xs text-muted"> <div class="flex items-center gap-2 text-xs text-muted">
<span class="flex size-7 items-center justify-center rounded-lg bg-orange-500/10 text-orange-600 dark:text-orange-400"> <span
class="flex size-7 items-center justify-center rounded-lg bg-orange-500/10 text-orange-600 dark:text-orange-400"
>
<UIcon <UIcon
name="i-lucide-tag" name="i-lucide-tag"
class="size-4" class="size-4"
/> />
</span> </span>
{{ t('tags.statUnused') }} {{ t("tags.statUnused") }}
</div> </div>
<p :class="statValueClass"> <p :class="statValueClass">
{{ stats.unused }} {{ stats.unused }}
@@ -407,13 +500,15 @@ async function confirmMerge() {
</div> </div>
<div class="rounded-xl border border-default bg-elevated p-4"> <div class="rounded-xl border border-default bg-elevated p-4">
<div class="flex items-center gap-2 text-xs text-muted"> <div class="flex items-center gap-2 text-xs text-muted">
<span class="flex size-7 items-center justify-center rounded-lg bg-violet-500/10 text-violet-600 dark:text-violet-400"> <span
class="flex size-7 items-center justify-center rounded-lg bg-violet-500/10 text-violet-600 dark:text-violet-400"
>
<UIcon <UIcon
name="i-lucide-trending-up" name="i-lucide-trending-up"
class="size-4" class="size-4"
/> />
</span> </span>
{{ t('tags.statNew7d') }} {{ t("tags.statNew7d") }}
</div> </div>
<p :class="statValueClass"> <p :class="statValueClass">
{{ stats.newIn7Days }} {{ stats.newIn7Days }}
@@ -443,7 +538,7 @@ async function confirmMerge() {
icon="i-lucide-plus" icon="i-lucide-plus"
@click="toggleCreating" @click="toggleCreating"
> >
{{ t('tags.addTag') }} {{ t("tags.addTag") }}
</UButton> </UButton>
<UButton <UButton
type="button" type="button"
@@ -454,7 +549,7 @@ async function confirmMerge() {
:disabled="selectedCount < 2" :disabled="selectedCount < 2"
@click="openBatchMerge" @click="openBatchMerge"
> >
{{ t('tags.mergeTags') }} {{ t("tags.mergeTags") }}
</UButton> </UButton>
</div> </div>
</div> </div>
@@ -464,7 +559,7 @@ async function confirmMerge() {
class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-2" class="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary/25 bg-primary/5 px-3 py-2"
> >
<span class="text-xs font-medium text-highlighted"> <span class="text-xs font-medium text-highlighted">
{{ t('tags.mergeSelected', { n: selectedCount }) }} {{ t("tags.mergeSelected", { n: selectedCount }) }}
</span> </span>
<UButton <UButton
size="xs" size="xs"
@@ -472,7 +567,7 @@ async function confirmMerge() {
color="neutral" color="neutral"
@click="clearSelection" @click="clearSelection"
> >
{{ t('tags.mergeClear') }} {{ t("tags.mergeClear") }}
</UButton> </UButton>
</div> </div>
@@ -498,11 +593,9 @@ async function confirmMerge() {
class="shrink-0 pt-1" class="shrink-0 pt-1"
> >
<p class="mb-1.5 text-xs text-muted"> <p class="mb-1.5 text-xs text-muted">
{{ t('tags.preview') }} {{ t("tags.preview") }}
</p> </p>
<TagBadge <TagBadge :tag="{ id: 0, name: newName.trim(), color: newColor }" />
:tag="{ id: 0, name: newName.trim(), color: newColor }"
/>
</div> </div>
</div> </div>
<div class="flex justify-end gap-2 border-t border-default pt-3"> <div class="flex justify-end gap-2 border-t border-default pt-3">
@@ -510,9 +603,13 @@ async function confirmMerge() {
size="sm" size="sm"
variant="ghost" variant="ghost"
color="neutral" color="neutral"
@click="() => { creating = false }" @click="
() => {
creating = false;
}
"
> >
{{ t('common.cancel') }} {{ t("common.cancel") }}
</UButton> </UButton>
<UButton <UButton
size="sm" size="sm"
@@ -521,59 +618,73 @@ async function confirmMerge() {
:disabled="!newName.trim()" :disabled="!newName.trim()"
@click="handleCreate" @click="handleCreate"
> >
{{ t('common.save') }} {{ t("common.save") }}
</UButton> </UButton>
</div> </div>
</div> </div>
<div class="overflow-hidden rounded-xl border border-default"> <div class="overflow-hidden rounded-xl border border-default">
<div class="overflow-x-auto"> <div
<table v-if="!paginatedTags.length"
class="min-w-full" class="px-3 py-12 text-center text-sm text-muted"
:class="tableTextClass" >
{{ tags.length ? t("tags.noMatch") : t("tags.emptyHint") }}
</div>
<!-- 手机:卡片列表 -->
<template v-else>
<div class="sm:hidden">
<div
class="flex items-center gap-2 border-b border-default bg-elevated/60 px-3 py-2.5 text-xs text-muted"
> >
<thead class="border-b border-default bg-elevated/60 text-xs text-muted">
<tr>
<th class="w-10 px-3 py-3">
<UCheckbox <UCheckbox
:model-value="allPageSelected" :model-value="allPageSelected"
:disabled="!paginatedTags.length" :disabled="!paginatedTags.length"
@update:model-value="toggleSelectAll" @update:model-value="toggleSelectAll"
/> />
</th> <span class="min-w-0 flex-1 font-medium">{{ t("tags.colName") }}</span>
<th class="px-3 py-3 text-left font-medium"> <button
{{ t('tags.colName') }} type="button"
</th> class="inline-flex shrink-0 items-center gap-0.5 font-medium text-inherit"
<th class="px-3 py-3 text-left font-medium"> @click="toggleSort('imageCount')"
{{ t('tags.colImageCount') }}
</th>
<th class="px-3 py-3 text-left font-medium">
{{ t('tags.colLastUsed') }}
</th>
<th class="px-3 py-3 text-right font-medium">
{{ t('tags.colActions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="tag in paginatedTags"
:key="tag.id"
class="border-b border-default last:border-b-0"
:class="selectedIds.has(tag.id) ? 'bg-primary/5' : 'hover:bg-elevated/40'"
> >
<td class="px-3 py-3 align-middle"> {{ t("tags.colImageCount") }}
<UIcon
:name="sortIcon('imageCount')"
:class="sortIconClass('imageCount')"
/>
</button>
<button
type="button"
class="inline-flex shrink-0 items-center gap-0.5 font-medium text-inherit"
@click="toggleSort('lastUsedAt')"
>
{{ t("tags.colLastUsed") }}
<UIcon
:name="sortIcon('lastUsedAt')"
:class="sortIconClass('lastUsedAt')"
/>
</button>
</div>
<article
v-for="tag in paginatedTags"
:key="`mobile-${tag.id}`"
class="border-b border-default px-3 py-3 last:border-b-0"
:class="selectedIds.has(tag.id) ? 'bg-primary/5' : ''"
>
<div class="flex items-start gap-2">
<UCheckbox <UCheckbox
class="mt-0.5"
:model-value="selectedIds.has(tag.id)" :model-value="selectedIds.has(tag.id)"
@update:model-value="toggleSelect(tag.id)" @update:model-value="toggleSelect(tag.id)"
/> />
</td> <div class="min-w-0 flex-1">
<td class="px-3 py-3 align-middle">
<UInput <UInput
v-if="renamingId === tag.id" v-if="renamingId === tag.id"
v-model="renameValue" v-model="renameValue"
size="sm" size="sm"
class="max-w-xs" class="w-full max-w-none"
autofocus autofocus
@keydown.enter.prevent="finishRename(tag.id)" @keydown.enter.prevent="finishRename(tag.id)"
@keydown.esc.prevent="cancelRename" @keydown.esc.prevent="cancelRename"
@@ -583,16 +694,21 @@ async function confirmMerge() {
v-else v-else
:tag="tag" :tag="tag"
size="sm" size="sm"
class="max-w-full truncate"
/> />
</td> <div
<td class="px-3 py-3 align-middle tabular-nums text-muted"> class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"
{{ tag.imageCount ?? 0 }} >
</td> <span class="tabular-nums">
<td class="px-3 py-3 align-middle text-muted"> {{ t("tags.colImageCount") }} {{ tag.imageCount ?? 0 }}
</span>
<span class="min-w-0 break-all">
{{ formatLastUsed(tag) }} {{ formatLastUsed(tag) }}
</td> </span>
<td class="px-3 py-3 align-middle"> </div>
<div class="flex items-center justify-end gap-0.5"> </div>
</div>
<div class="mt-2 flex flex-wrap justify-end gap-0.5">
<UButton <UButton
type="button" type="button"
size="xs" size="xs"
@@ -605,7 +721,11 @@ async function confirmMerge() {
<UPopover <UPopover
:open="colorPickerId === tag.id" :open="colorPickerId === tag.id"
:content="{ side: 'bottom', align: 'end' }" :content="{ side: 'bottom', align: 'end' }"
@update:open="(open) => { colorPickerId = open ? tag.id : null }" @update:open="
(open) => {
colorPickerId = open ? tag.id : null;
}
"
> >
<UButton <UButton
type="button" type="button"
@@ -658,19 +778,178 @@ async function confirmMerge() {
@click="openDeleteConfirm(tag)" @click="openDeleteConfirm(tag)"
/> />
</div> </div>
</td> </article>
</tr>
<tr v-if="!paginatedTags.length">
<td
colspan="5"
class="px-3 py-12 text-center text-sm text-muted"
>
{{ tags.length ? t('tags.noMatch') : t('tags.emptyHint') }}
</td>
</tr>
</tbody>
</table>
</div> </div>
<!-- 桌面:表格 -->
<div class="hidden sm:block">
<div
class="border-b border-default bg-elevated/60 text-xs text-muted"
:class="[tagRowClass, tableTextClass, 'py-2.5']"
>
<div class="flex w-10 shrink-0 justify-center">
<UCheckbox
:model-value="allPageSelected"
:disabled="!paginatedTags.length"
@update:model-value="toggleSelectAll"
/>
</div>
<div :class="[tagNameColClass, 'font-medium']">
{{ t("tags.colName") }}
</div>
<div :class="tagRightGroupClass">
<div :class="[tagCountColClass, 'font-medium']">
<button
type="button"
class="inline-flex items-center gap-0.5 font-medium text-inherit"
@click="toggleSort('imageCount')"
>
{{ t("tags.colImageCount") }}
<UIcon
:name="sortIcon('imageCount')"
:class="sortIconClass('imageCount')"
/>
</button>
</div>
<div :class="[tagLastColClass, 'font-medium text-muted']">
<button
type="button"
class="inline-flex items-center gap-0.5 font-medium text-inherit"
@click="toggleSort('lastUsedAt')"
>
{{ t("tags.colLastUsed") }}
<UIcon
:name="sortIcon('lastUsedAt')"
:class="sortIconClass('lastUsedAt')"
/>
</button>
</div>
<div :class="[tagActionsColClass, 'text-right font-medium']">
{{ t("tags.colActions") }}
</div>
</div>
</div>
<div
v-for="tag in paginatedTags"
:key="tag.id"
class="border-b border-default last:border-b-0"
:class="[
tagRowClass,
tableTextClass,
'py-0',
selectedIds.has(tag.id) ? 'bg-primary/5' : 'hover:bg-elevated/40'
]"
>
<div class="flex w-10 shrink-0 justify-center py-3">
<UCheckbox
:model-value="selectedIds.has(tag.id)"
@update:model-value="toggleSelect(tag.id)"
/>
</div>
<div :class="[tagNameColClass, 'py-3']">
<UInput
v-if="renamingId === tag.id"
v-model="renameValue"
size="sm"
class="max-w-xs"
autofocus
@keydown.enter.prevent="finishRename(tag.id)"
@keydown.esc.prevent="cancelRename"
@blur="onRenameBlur(tag.id)"
/>
<TagBadge
v-else
:tag="tag"
size="sm"
/>
</div>
<div :class="[tagRightGroupClass, 'py-3']">
<div :class="[tagCountColClass, 'text-muted']">
{{ tag.imageCount ?? 0 }}
</div>
<div :class="tagLastColClass">
{{ formatLastUsed(tag) }}
</div>
<div
:class="[
tagActionsColClass,
'flex items-center justify-end gap-0.5'
]"
>
<UButton
type="button"
size="xs"
variant="ghost"
color="primary"
icon="i-lucide-pencil"
:aria-label="t('common.edit')"
@click="startRename(tag)"
/>
<UPopover
:open="colorPickerId === tag.id"
:content="{ side: 'bottom', align: 'end' }"
@update:open="
(open) => {
colorPickerId = open ? tag.id : null;
}
"
>
<UButton
type="button"
size="xs"
variant="ghost"
color="primary"
icon="i-lucide-palette"
:aria-label="t('tags.pickColor')"
@click="() => openColorPicker(tag)"
/>
<template #content>
<div
class="flex items-center gap-2 p-2"
@click.stop
>
<TagColorPicker
v-model="colorValue"
:candidates="editColorCandidates"
compact
/>
<UButton
icon="i-lucide-check"
size="sm"
color="primary"
class="shrink-0"
:aria-label="t('common.save')"
:loading="saving"
@click="saveColor(tag.id)"
/>
</div>
</template>
</UPopover>
<UButton
type="button"
size="xs"
variant="ghost"
color="primary"
icon="i-lucide-merge"
:aria-label="t('tags.mergeIntoAction')"
:disabled="tags.length < 2"
@click="openSingleMerge(tag)"
/>
<UButton
type="button"
size="xs"
variant="ghost"
color="error"
icon="i-lucide-trash-2"
:aria-label="t('common.delete')"
@click="openDeleteConfirm(tag)"
/>
</div>
</div>
</div>
</div>
</template>
</div> </div>
<PaginationBar <PaginationBar
@@ -680,28 +959,46 @@ async function confirmMerge() {
:total="filteredTags.length" :total="filteredTags.length"
:page-size="pageSize" :page-size="pageSize"
:page-size-options="[10, 20, 30, 50]" :page-size-options="[10, 20, 30, 50]"
@update:page="(value) => { page = value }" @update:page="
@update:page-size="(value) => { pageSize = value }" (value) => {
page = value;
}
"
@update:page-size="
(value) => {
pageSize = value;
}
"
/> />
<UModal <UModal
v-model:open="mergeOpen" v-model:open="mergeOpen"
:title="mergeMode === 'single' ? t('tags.mergeIntoTitle') : t('tags.mergeTitle')" :title="
:description="mergeMode === 'single' mergeMode === 'single' ? t('tags.mergeIntoTitle') : t('tags.mergeTitle')
? t('tags.mergeIntoSubtitle', { name: mergeSourceTags[0]?.name ?? '' }) "
: t('tags.mergeSubtitle')" :description="
mergeMode === 'single'
? t('tags.mergeIntoSubtitle', {
name: mergeSourceTags[0]?.name ?? ''
})
: t('tags.mergeSubtitle')
"
> >
<template #body> <template #body>
<template v-if="mergeMode === 'batch'"> <template v-if="mergeMode === 'batch'">
<p class="mb-2 text-sm text-muted"> <p class="mb-2 text-sm text-muted">
{{ t('tags.mergeChooseTarget') }} {{ t("tags.mergeChooseTarget") }}
</p> </p>
<div class="merge-tag-list max-h-[16.5rem] space-y-2 overflow-y-auto"> <div class="merge-tag-list max-h-[16.5rem] space-y-2 overflow-y-auto">
<label <label
v-for="tag in mergeSourceTags" v-for="tag in mergeSourceTags"
:key="tag.id" :key="tag.id"
class="flex cursor-pointer items-center gap-3 rounded-lg border border-default px-3 py-2.5 transition-colors" class="flex cursor-pointer items-center gap-3 rounded-lg border border-default px-3 py-2.5 transition-colors"
:class="mergeTargetId === tag.id ? 'border-primary/40 bg-primary/5' : 'hover:bg-elevated'" :class="
mergeTargetId === tag.id
? 'border-primary/40 bg-primary/5'
: 'hover:bg-elevated'
"
> >
<input <input
v-model="mergeTargetId" v-model="mergeTargetId"
@@ -711,7 +1008,7 @@ async function confirmMerge() {
> >
<TagBadge :tag="tag" /> <TagBadge :tag="tag" />
<span class="ml-auto text-sm text-muted"> <span class="ml-auto text-sm text-muted">
{{ t('common.countImages', { count: tag.imageCount ?? 0 }) }} {{ t("common.countImages", { count: tag.imageCount ?? 0 }) }}
</span> </span>
</label> </label>
</div> </div>
@@ -719,14 +1016,18 @@ async function confirmMerge() {
<template v-else> <template v-else>
<p class="mb-2 text-sm text-muted"> <p class="mb-2 text-sm text-muted">
{{ t('tags.mergeIntoChoose') }} {{ t("tags.mergeIntoChoose") }}
</p> </p>
<div class="merge-tag-list max-h-[16.5rem] space-y-2 overflow-y-auto"> <div class="merge-tag-list max-h-[16.5rem] space-y-2 overflow-y-auto">
<label <label
v-for="tag in mergeTargetCandidates" v-for="tag in mergeTargetCandidates"
:key="tag.id" :key="tag.id"
class="flex cursor-pointer items-center gap-3 rounded-lg border border-default px-3 py-2.5 transition-colors" class="flex cursor-pointer items-center gap-3 rounded-lg border border-default px-3 py-2.5 transition-colors"
:class="mergeTargetId === tag.id ? 'border-primary/40 bg-primary/5' : 'hover:bg-elevated'" :class="
mergeTargetId === tag.id
? 'border-primary/40 bg-primary/5'
: 'hover:bg-elevated'
"
> >
<input <input
v-model="mergeTargetId" v-model="mergeTargetId"
@@ -736,7 +1037,7 @@ async function confirmMerge() {
> >
<TagBadge :tag="tag" /> <TagBadge :tag="tag" />
<span class="ml-auto text-sm text-muted"> <span class="ml-auto text-sm text-muted">
{{ t('common.countImages', { count: tag.imageCount ?? 0 }) }} {{ t("common.countImages", { count: tag.imageCount ?? 0 }) }}
</span> </span>
</label> </label>
</div> </div>
@@ -748,9 +1049,13 @@ async function confirmMerge() {
<UButton <UButton
variant="outline" variant="outline"
color="neutral" color="neutral"
@click="() => { mergeOpen = false }" @click="
() => {
mergeOpen = false;
}
"
> >
{{ t('common.cancel') }} {{ t("common.cancel") }}
</UButton> </UButton>
<UButton <UButton
color="primary" color="primary"
@@ -758,7 +1063,7 @@ async function confirmMerge() {
:disabled="!mergeTargetId" :disabled="!mergeTargetId"
@click="requestMergeConfirm" @click="requestMergeConfirm"
> >
{{ t('tags.mergeConfirm') }} {{ t("tags.mergeConfirm") }}
</UButton> </UButton>
</div> </div>
</template> </template>
@@ -772,12 +1077,14 @@ async function confirmMerge() {
<template #body> <template #body>
<div class="space-y-3 text-sm"> <div class="space-y-3 text-sm">
<p v-if="mergeKeepTag"> <p v-if="mergeKeepTag">
{{ t('tags.mergeConfirmKeep', { name: mergeKeepTag.name }) }} {{ t("tags.mergeConfirmKeep", { name: mergeKeepTag.name }) }}
</p> </p>
<p v-if="mergeTagsToDelete.length"> <p v-if="mergeTagsToDelete.length">
{{ t('tags.mergeWillDelete', { {{
names: mergeTagsToDelete.map(tag => tag.name).join('、') t("tags.mergeWillDelete", {
}) }} names: mergeTagsToDelete.map((tag) => tag.name).join("、")
})
}}
</p> </p>
</div> </div>
</template> </template>
@@ -787,9 +1094,13 @@ async function confirmMerge() {
<UButton <UButton
variant="outline" variant="outline"
color="neutral" color="neutral"
@click="() => { mergeConfirmOpen = false }" @click="
() => {
mergeConfirmOpen = false;
}
"
> >
{{ t('common.cancel') }} {{ t("common.cancel") }}
</UButton> </UButton>
<UButton <UButton
color="primary" color="primary"
@@ -797,7 +1108,7 @@ async function confirmMerge() {
:loading="saving" :loading="saving"
@click="confirmMerge" @click="confirmMerge"
> >
{{ t('tags.mergeConfirm') }} {{ t("tags.mergeConfirm") }}
</UButton> </UButton>
</div> </div>
</template> </template>
@@ -806,16 +1117,24 @@ async function confirmMerge() {
<UModal <UModal
v-model:open="deleteOpen" v-model:open="deleteOpen"
:title="t('tags.deleteConfirmTitle')" :title="t('tags.deleteConfirmTitle')"
:description="deleteTarget ? t('tags.deleteConfirmDesc', { name: deleteTarget.name }) : ''" :description="
deleteTarget
? t('tags.deleteConfirmDesc', { name: deleteTarget.name })
: ''
"
> >
<template #footer> <template #footer>
<div class="flex w-full justify-end gap-2"> <div class="flex w-full justify-end gap-2">
<UButton <UButton
variant="outline" variant="outline"
color="neutral" color="neutral"
@click="() => { deleteOpen = false }" @click="
() => {
deleteOpen = false;
}
"
> >
{{ t('common.cancel') }} {{ t("common.cancel") }}
</UButton> </UButton>
<UButton <UButton
color="error" color="error"
@@ -823,7 +1142,7 @@ async function confirmMerge() {
:loading="saving" :loading="saving"
@click="confirmDelete" @click="confirmDelete"
> >
{{ t('common.delete') }} {{ t("common.delete") }}
</UButton> </UButton>
</div> </div>
</template> </template>
+16 -9
View File
@@ -38,19 +38,26 @@ const { t } = useI18n()
</h3> </h3>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full min-w-[32rem] text-left text-sm"> <table class="w-full min-w-[36rem] table-fixed text-left text-sm">
<colgroup>
<col class="w-[5.5rem]">
<col class="w-[3.5rem]">
<col class="w-[5.75rem]">
<col class="w-[3.25rem]">
<col>
</colgroup>
<thead> <thead>
<tr class="border-b border-default text-xs text-muted"> <tr class="border-b border-default text-xs text-muted">
<th class="px-4 py-2.5 font-medium"> <th class="px-4 py-2.5 font-medium">
{{ t('api.paramName') }} {{ t('api.paramName') }}
</th> </th>
<th class="px-4 py-2.5 font-medium"> <th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramType') }} {{ t('api.paramType') }}
</th> </th>
<th class="px-4 py-2.5 font-medium"> <th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramLocation') }} {{ t('api.paramLocation') }}
</th> </th>
<th class="px-4 py-2.5 font-medium"> <th class="whitespace-nowrap px-4 py-2.5 font-medium">
{{ t('api.paramRequired') }} {{ t('api.paramRequired') }}
</th> </th>
<th class="px-4 py-2.5 font-medium"> <th class="px-4 py-2.5 font-medium">
@@ -64,20 +71,20 @@ const { t } = useI18n()
:key="param.name" :key="param.name"
class="border-b border-default last:border-b-0" class="border-b border-default last:border-b-0"
> >
<td class="px-4 py-2.5 font-mono text-xs"> <td class="whitespace-nowrap px-4 py-2.5 font-mono text-xs">
{{ param.name }} {{ param.name }}
<span <span
v-if="param.required" v-if="param.required"
class="text-error" class="text-error"
>*</span> >*</span>
</td> </td>
<td class="px-4 py-2.5 text-muted"> <td class="whitespace-nowrap px-4 py-2.5 text-muted">
{{ param.type }} {{ param.type }}
</td> </td>
<td class="px-4 py-2.5 text-muted"> <td class="whitespace-nowrap px-4 py-2.5 text-muted">
{{ param.location }} {{ param.location }}
</td> </td>
<td class="px-4 py-2.5"> <td class="whitespace-nowrap px-4 py-2.5">
<UBadge <UBadge
:color="param.required ? 'primary' : 'neutral'" :color="param.required ? 'primary' : 'neutral'"
variant="subtle" variant="subtle"
@@ -86,7 +93,7 @@ const { t } = useI18n()
{{ param.required ? t('api.required') : t('api.optional') }} {{ param.required ? t('api.required') : t('api.optional') }}
</UBadge> </UBadge>
</td> </td>
<td class="px-4 py-2.5 text-muted"> <td class="px-4 py-2.5 leading-relaxed text-muted break-words">
{{ t(param.descriptionKey) }} {{ t(param.descriptionKey) }}
</td> </td>
</tr> </tr>
-9
View File
@@ -75,15 +75,6 @@ async function copyToken() {
:description="t('api.envOverrideDesc')" :description="t('api.envOverrideDesc')"
/> />
<UAlert
v-else-if="!isAdmin"
color="info"
variant="subtle"
icon="i-lucide-info"
:title="t('api.personalTitle')"
:description="t('api.personalDesc')"
/>
<UAlert <UAlert
v-else-if="!token && !loading" v-else-if="!token && !loading"
color="info" color="info"
@@ -0,0 +1,329 @@
<script setup lang="ts">
type PolicySource = 'env' | 'db' | 'default'
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
defineProps<{
part: 'upload-preference' | 'image-processing' | 'login-rate'
uploadMaxFileSizeMbSource?: PolicySource
allowedMimeTypesSource?: PolicySource
uploadRateIpMaxSource?: PolicySource
uploadRateTokenMaxSource?: PolicySource
uploadRateWindowMinutesSource?: PolicySource
loginRateMaxSource?: PolicySource
loginRateWindowMinutesSource?: PolicySource
preserveOriginalUploadSource?: PolicySource
mimeTypeOptions?: ReadonlyArray<{ mime: MimeType, label: string }>
}>()
const uploadMaxFileSizeMbDraft = defineModel<number>('uploadMaxFileSizeMb')
const allowedMimeTypesDraft = defineModel<MimeType[]>('allowedMimeTypes')
const uploadRateIpMaxDraft = defineModel<number>('uploadRateIpMax')
const uploadRateTokenMaxDraft = defineModel<number>('uploadRateTokenMax')
const uploadRateWindowMinutesDraft = defineModel<number>('uploadRateWindowMinutes')
const loginRateMaxDraft = defineModel<number>('loginRateMax')
const loginRateWindowMinutesDraft = defineModel<number>('loginRateWindowMinutes')
const preserveOriginalUploadDraft = defineModel<boolean>('preserveOriginalUpload')
const { t } = useI18n()
function sourceBadge(source: PolicySource) {
switch (source) {
case 'env':
return { label: t('settings.badgeEnv'), color: 'warning' as const }
case 'db':
return { label: t('settings.badgeSaved'), color: 'success' as const }
default:
return { label: t('settings.badgeDefault'), color: 'neutral' as const }
}
}
function toggleMime(mime: MimeType, checked: boolean) {
if (!allowedMimeTypesDraft.value) return
if (checked) {
if (!allowedMimeTypesDraft.value.includes(mime)) {
allowedMimeTypesDraft.value = [...allowedMimeTypesDraft.value, mime]
}
return
}
if (allowedMimeTypesDraft.value.length <= 1) return
allowedMimeTypesDraft.value = allowedMimeTypesDraft.value.filter(item => item !== mime)
}
function isMimeChecked(mime: MimeType): boolean {
return allowedMimeTypesDraft.value?.includes(mime) ?? false
}
function clampUploadMaxFileSizeMb(value: unknown): number {
const num = Number(value)
if (!Number.isFinite(num)) return uploadMaxFileSizeMbDraft.value ?? 10
return Math.min(100, Math.max(1, Math.round(num)))
}
function onUploadMaxFileSizeInput(event: Event) {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(
(event.target as HTMLInputElement).value
)
}
function onUploadMaxFileSizeNumberInput(value: string | number) {
uploadMaxFileSizeMbDraft.value = clampUploadMaxFileSizeMb(value)
}
</script>
<template>
<template v-if="part === 'upload-preference'">
<div class="py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.uploadMaxFileSize') }}
</p>
<UBadge
v-if="uploadMaxFileSizeMbSource"
:color="sourceBadge(uploadMaxFileSizeMbSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadMaxFileSizeMbSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.uploadMaxFileSizeHint') }}
</p>
<div
class="mt-3 flex items-center gap-3"
:class="uploadMaxFileSizeMbSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<input
type="range"
min="1"
max="100"
:value="uploadMaxFileSizeMbDraft"
class="h-1.5 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-muted accent-primary"
:disabled="uploadMaxFileSizeMbSource === 'env'"
@input="onUploadMaxFileSizeInput"
>
<div class="flex shrink-0 items-center gap-1.5">
<UInput
:model-value="uploadMaxFileSizeMbDraft"
type="number"
:min="1"
:max="100"
size="xs"
class="w-14 tabular-nums"
:disabled="uploadMaxFileSizeMbSource === 'env'"
@update:model-value="onUploadMaxFileSizeNumberInput"
/>
<span class="text-xs text-muted">MB</span>
</div>
</div>
</div>
<div class="py-4">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.allowedMimeTypes') }}
</p>
<UBadge
v-if="allowedMimeTypesSource"
:color="sourceBadge(allowedMimeTypesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(allowedMimeTypesSource).label }}
</UBadge>
</div>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.allowedMimeTypesHint') }}
</p>
<div
class="mt-3 flex flex-wrap gap-2"
:class="allowedMimeTypesSource === 'env' ? 'pointer-events-none opacity-60' : ''"
>
<label
v-for="option in mimeTypeOptions ?? []"
:key="option.mime"
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors"
:class="isMimeChecked(option.mime)
? 'border-primary/40 bg-primary/5'
: 'border-default bg-muted/20 hover:bg-muted/40'"
:title="option.mime"
>
<UCheckbox
:model-value="isMimeChecked(option.mime)"
@update:model-value="(v) => toggleMime(option.mime, v === true)"
@click.stop
/>
<span class="font-medium text-highlighted">{{ option.label }}</span>
</label>
</div>
</div>
<div class="py-4">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.uploadRateGroup') }}
</p>
<p class="mt-1 text-xs leading-relaxed text-muted">
{{ t('settings.uploadRateFootnote') }}
</p>
<div class="mt-3 grid gap-4 sm:grid-cols-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateIpMax') }}
</p>
<UBadge
v-if="uploadRateIpMaxSource"
:color="sourceBadge(uploadRateIpMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateIpMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="uploadRateIpMaxDraft"
type="number"
:min="1"
:max="1000"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="uploadRateIpMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateTokenMax') }}
</p>
<UBadge
v-if="uploadRateTokenMaxSource"
:color="sourceBadge(uploadRateTokenMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateTokenMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="uploadRateTokenMaxDraft"
type="number"
:min="1"
:max="5000"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="uploadRateTokenMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs font-medium text-muted">
{{ t('settings.uploadRateWindowMinutes') }}
</p>
<UBadge
v-if="uploadRateWindowMinutesSource"
:color="sourceBadge(uploadRateWindowMinutesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(uploadRateWindowMinutesSource).label }}
</UBadge>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
v-model.number="uploadRateWindowMinutesDraft"
type="number"
:min="1"
:max="1440"
size="sm"
class="w-full tabular-nums"
:disabled="uploadRateWindowMinutesSource === 'env'"
/>
<span class="shrink-0 text-xs text-muted">{{ t('settings.minutesUnit') }}</span>
</div>
</div>
</div>
</div>
</template>
<SettingsToggleRow
v-else-if="part === 'image-processing'"
v-model="preserveOriginalUploadDraft!"
:disabled="preserveOriginalUploadSource === 'env'"
>
<template #title>
<span class="inline-flex flex-wrap items-center gap-2">
{{ t('settings.preserveOriginalUpload') }}
<UBadge
v-if="preserveOriginalUploadSource"
:color="sourceBadge(preserveOriginalUploadSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(preserveOriginalUploadSource).label }}
</UBadge>
</span>
</template>
<template #hint>
{{ t('settings.preserveOriginalUploadHint') }}
</template>
</SettingsToggleRow>
<template v-else-if="part === 'login-rate'">
<div class="grid gap-4 py-4 sm:grid-cols-2">
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.loginRateMax') }}
</p>
<UBadge
v-if="loginRateMaxSource"
:color="sourceBadge(loginRateMaxSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(loginRateMaxSource).label }}
</UBadge>
</div>
<UInput
v-model.number="loginRateMaxDraft"
type="number"
:min="1"
:max="100"
size="sm"
class="mt-2 w-full tabular-nums"
:disabled="loginRateMaxSource === 'env'"
/>
</div>
<div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-highlighted">
{{ t('settings.loginRateWindowMinutes') }}
</p>
<UBadge
v-if="loginRateWindowMinutesSource"
:color="sourceBadge(loginRateWindowMinutesSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(loginRateWindowMinutesSource).label }}
</UBadge>
</div>
<div class="mt-2 flex items-center gap-2">
<UInput
v-model.number="loginRateWindowMinutesDraft"
type="number"
:min="1"
:max="1440"
size="sm"
class="w-full tabular-nums"
:disabled="loginRateWindowMinutesSource === 'env'"
/>
<span class="shrink-0 text-xs text-muted">{{ t('settings.minutesUnit') }}</span>
</div>
</div>
</div>
<p class="pb-4 text-xs leading-relaxed text-muted">
{{ t('settings.loginRateFootnote') }}
</p>
</template>
</template>
@@ -1,4 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{
part: 'upload-preference' | 'image-processing'
}>()
const { const {
compressEnabled, compressEnabled,
autoCopyMarkdown, autoCopyMarkdown,
@@ -44,8 +48,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<SettingsSection :title="t('preferences.processing')"> <template v-if="part === 'image-processing'">
<SettingsGroup>
<SettingsToggleRow <SettingsToggleRow
v-model="compressEnabled" v-model="compressEnabled"
:title="t('preferences.clientCompress')" :title="t('preferences.clientCompress')"
@@ -116,11 +119,9 @@ onMounted(() => {
/> />
</div> </div>
</div> </div>
</SettingsGroup> </template>
</SettingsSection>
<SettingsSection :title="t('preferences.title')"> <template v-else>
<SettingsGroup>
<SettingsToggleRow <SettingsToggleRow
v-model="autoCopyMarkdown" v-model="autoCopyMarkdown"
:title="t('preferences.autoCopy')" :title="t('preferences.autoCopy')"
@@ -149,6 +150,5 @@ onMounted(() => {
/> />
</div> </div>
</div> </div>
</SettingsGroup> </template>
</SettingsSection>
</template> </template>
+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>
+9 -1
View File
@@ -73,11 +73,19 @@ export function buildApiDocs(ctx: ApiDocsContext): ApiDocEndpoint[] {
location: 'form-data', location: 'form-data',
required: false, required: false,
descriptionKey: 'api.params.upload.token' descriptionKey: 'api.params.upload.token'
},
{
name: 'tagNames',
type: 'string',
location: 'form-data',
required: false,
descriptionKey: 'api.params.upload.tagNames'
} }
], ],
curl: `curl -X POST "${baseUrl}/api/images/upload" \\ curl: `curl -X POST "${baseUrl}/api/images/upload" \\
${auth} \\ ${auth} \\
-F "image=@./demo.png"`, -F "image=@./demo.png" \\
-F 'tagNames=["博客","截图"]'`,
responseExample: `{ responseExample: `{
"success": true, "success": true,
"items": [ "items": [
+7 -1
View File
@@ -11,6 +11,7 @@ export function useImageUpload() {
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const { compressEnabled, clientWebpQuality, lastTagIds } = useUploadPreferences() const { compressEnabled, clientWebpQuality, lastTagIds } = useUploadPreferences()
const { items: tagItems } = useTags()
const uploading = ref(false) const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([]) const progressItems = ref<UploadProgressItem[]>([])
const lastUploadResult = ref<UploadResponse | null>(null) const lastUploadResult = ref<UploadResponse | null>(null)
@@ -60,7 +61,12 @@ export function useImageUpload() {
const tagIds = options?.tagIds ?? lastTagIds.value const tagIds = options?.tagIds ?? lastTagIds.value
if (tagIds.length) { if (tagIds.length) {
formData.append('tagIds', JSON.stringify(tagIds)) const names = tagIds
.map(id => tagItems.value.find(tag => tag.id === id)?.name)
.filter((name): name is string => Boolean(name))
if (names.length) {
formData.append('tagNames', JSON.stringify(names))
}
lastTagIds.value = tagIds lastTagIds.value = tagIds
} }
+15 -31
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
interface SettingsResponse { interface UserTokenResponse {
apiUploadToken: string apiUploadToken: string
tokenSource: 'env' | 'db' | 'none' tokenSource: 'env' | 'db' | 'none'
envTokenOverride: boolean envTokenOverride: boolean
@@ -16,7 +16,7 @@ const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus,
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const settings = ref<SettingsResponse | null>(null) const tokenSettings = ref<UserTokenResponse | null>(null)
const loading = ref(false) const loading = ref(false)
const regenerating = ref(false) const regenerating = ref(false)
const confirmRegenerateOpen = ref(false) const confirmRegenerateOpen = ref(false)
@@ -25,19 +25,17 @@ const newToken = ref('')
const activeEndpointId = ref('upload') const activeEndpointId = ref('upload')
const baseUrl = computed(() => { const baseUrl = computed(() => {
const configured = settings.value?.env.siteBaseUrl const configured = tokenSettings.value?.env.siteBaseUrl
if (configured) return configured if (configured) return configured
if (import.meta.client) { if (import.meta.client) {
return window.location.origin return window.location.origin
} }
return settings.value?.env.imageBaseUrl ?? '' return tokenSettings.value?.env.imageBaseUrl ?? ''
}) })
const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '') const tokenDisplay = computed(() => tokenSettings.value?.apiUploadToken ?? '')
const hasToken = computed(() => tokenDisplay.value.length > 0) const hasToken = computed(() => tokenDisplay.value.length > 0)
const canRegenerate = computed(() => const canRegenerate = computed(() => !loading.value)
!settings.value?.envTokenOverride && !loading.value
)
const authHeader = computed(() => const authHeader = computed(() =>
hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN' hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN'
@@ -54,11 +52,10 @@ const activeEndpoint = computed(() =>
apiDocs.value.find(endpoint => endpoint.id === activeEndpointId.value) ?? apiDocs.value[0] apiDocs.value.find(endpoint => endpoint.id === activeEndpointId.value) ?? apiDocs.value[0]
) )
async function loadSettings() { async function loadTokenSettings() {
loading.value = true loading.value = true
try { try {
const endpoint = isAdmin.value ? '/api/settings' : '/api/user/api-token' tokenSettings.value = await $fetch<UserTokenResponse>('/api/user/api-token', {
settings.value = await $fetch<SettingsResponse>(endpoint, {
credentials: 'include' credentials: 'include'
}) })
} catch (error: unknown) { } catch (error: unknown) {
@@ -66,6 +63,7 @@ async function loadSettings() {
if (isAuthenticated.value) { if (isAuthenticated.value) {
toast.add({ title: t('api.loadFailed'), color: 'error' }) toast.add({ title: t('api.loadFailed'), color: 'error' })
} }
tokenSettings.value = null
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -80,31 +78,18 @@ async function confirmRegenerate() {
confirmRegenerateOpen.value = false confirmRegenerateOpen.value = false
regenerating.value = true regenerating.value = true
try { try {
const endpoint = isAdmin.value
? '/api/settings/api-token/regenerate'
: '/api/user/api-token/regenerate'
const response = await $fetch<{ apiUploadToken: string }>( const response = await $fetch<{ apiUploadToken: string }>(
endpoint, '/api/user/api-token/regenerate',
{ method: 'POST', credentials: 'include' } { method: 'POST', credentials: 'include' }
) )
newToken.value = response.apiUploadToken newToken.value = response.apiUploadToken
regenerateResultOpen.value = true regenerateResultOpen.value = true
await loadSettings() await loadTokenSettings()
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
if (isAuthenticated.value) { if (isAuthenticated.value) {
const status = typeof error === 'object' && error !== null && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: 0
if (status === 409) {
toast.add({
title: t('api.regenerateEnvBlocked'),
color: 'warning'
})
} else {
toast.add({ title: t('api.regenerateFailed'), color: 'error' }) toast.add({ title: t('api.regenerateFailed'), color: 'error' })
} }
}
} finally { } finally {
regenerating.value = false regenerating.value = false
} }
@@ -126,16 +111,16 @@ onMounted(async () => {
} }
await checkSession() await checkSession()
if (isAuthenticated.value) { if (isAuthenticated.value) {
await loadSettings() await loadTokenSettings()
} }
}) })
watch(isAuthenticated, async (authed, prev) => { watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) { if (authed && prev === false) {
await nextTick() await nextTick()
await loadSettings() await loadTokenSettings()
} else { } else {
settings.value = null tokenSettings.value = null
} }
}) })
</script> </script>
@@ -174,8 +159,7 @@ watch(isAuthenticated, async (authed, prev) => {
:loading="loading" :loading="loading"
:regenerating="regenerating" :regenerating="regenerating"
:can-regenerate="canRegenerate" :can-regenerate="canRegenerate"
:token-source="settings?.tokenSource" token-source="db"
:env-token-override="settings?.envTokenOverride"
:is-admin="isAdmin" :is-admin="isAdmin"
@regenerate="requestRegenerate" @regenerate="requestRegenerate"
/> />
+5 -2
View File
@@ -57,7 +57,7 @@ const {
const { items: tagItems, fetchTags, batchUpdateTags } = useTags() const { items: tagItems, fetchTags, batchUpdateTags } = useTags()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth() const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, getFetchErrorMessage } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
@@ -256,7 +256,10 @@ async function confirmBatchTags(tagIds: number[], action: 'add' | 'remove') {
await Promise.all([reloadGallery(), fetchStats(), fetchTags(true)]) await Promise.all([reloadGallery(), fetchStats(), fetchTags(true)])
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
toast.add({ title: t('tags.batchFailed'), color: 'error' }) toast.add({
title: getFetchErrorMessage(error, t('tags.batchFailed')),
color: 'error'
})
} }
} }
+114 -2
View File
@@ -3,10 +3,13 @@ import type { SettingsTab } from '~/types/settings'
import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings' import { ADMIN_SETTINGS_TABS, USER_SETTINGS_TABS } from '~/types/settings'
import { willActivateDomainSeparation } from '~/utils/domain-separation' import { willActivateDomainSeparation } from '~/utils/domain-separation'
import SettingsUserPreferencesFields from '~/components/settings/SettingsUserPreferencesFields.vue' import SettingsUserPreferencesFields from '~/components/settings/SettingsUserPreferencesFields.vue'
import SettingsUploadPolicyFields from '~/components/settings/SettingsUploadPolicyFields.vue'
type SettingSource = 'env' | 'db' | 'none' type SettingSource = 'env' | 'db' | 'none'
type WebpQualitySource = 'env' | 'db' | 'default' type WebpQualitySource = 'env' | 'db' | 'default'
type PolicySource = 'env' | 'db' | 'default'
type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap' type LoginVerificationMethod = 'slider' | 'turnstile' | 'cap'
type MimeType = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/svg+xml' | 'image/x-icon'
interface SettingsResponse { interface SettingsResponse {
apiUploadToken: string apiUploadToken: string
@@ -42,6 +45,23 @@ interface SettingsResponse {
capApiEndpoint: string capApiEndpoint: string
capSecret: string capSecret: string
appVersion: string appVersion: string
uploadMaxFileSizeMb: number
uploadMaxFileSizeMbSource: PolicySource
allowedMimeTypes: MimeType[]
allowedMimeTypesSource: PolicySource
uploadRateIpMax: number
uploadRateIpMaxSource: PolicySource
uploadRateTokenMax: number
uploadRateTokenMaxSource: PolicySource
uploadRateWindowMinutes: number
uploadRateWindowMinutesSource: PolicySource
loginRateMax: number
loginRateMaxSource: PolicySource
loginRateWindowMinutes: number
loginRateWindowMinutesSource: PolicySource
preserveOriginalUpload: boolean
preserveOriginalUploadSource: PolicySource
mimeTypeOptions: ReadonlyArray<{ mime: MimeType, label: string }>
} }
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth() const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
@@ -143,6 +163,15 @@ const disableDomainSeparationOpen = ref(false)
const pendingDomainSeparationDisable = ref(false) const pendingDomainSeparationDisable = ref(false)
const domainSeparationSaveConfirmOpen = ref(false) const domainSeparationSaveConfirmOpen = ref(false)
const uploadMaxFileSizeMbDraft = ref(10)
const allowedMimeTypesDraft = ref<MimeType[]>([])
const uploadRateIpMaxDraft = ref(60)
const uploadRateTokenMaxDraft = ref(120)
const uploadRateWindowMinutesDraft = ref(15)
const loginRateMaxDraft = ref(10)
const loginRateWindowMinutesDraft = ref(15)
const preserveOriginalUploadDraft = ref(false)
interface ReleaseCheckResponse { interface ReleaseCheckResponse {
currentVersion: string currentVersion: string
latestVersion: string | null latestVersion: string | null
@@ -152,6 +181,10 @@ interface ReleaseCheckResponse {
const releaseCheck = ref<ReleaseCheckResponse | null>(null) const releaseCheck = ref<ReleaseCheckResponse | null>(null)
function mimeListsEqual(left: readonly string[], right: readonly string[]): boolean {
return left.slice().sort().join(',') === right.slice().sort().join(',')
}
const hasServerChanges = computed(() => { const hasServerChanges = computed(() => {
if (!settings.value) return false if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts return refererDraft.value !== settings.value.allowedRefererHosts
@@ -166,6 +199,14 @@ const hasServerChanges = computed(() => {
|| turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey || turnstileSecretKeyDraft.value !== settings.value.turnstileSecretKey
|| capApiEndpointDraft.value !== settings.value.capApiEndpoint || capApiEndpointDraft.value !== settings.value.capApiEndpoint
|| capSecretDraft.value !== settings.value.capSecret || capSecretDraft.value !== settings.value.capSecret
|| uploadMaxFileSizeMbDraft.value !== settings.value.uploadMaxFileSizeMb
|| !mimeListsEqual(allowedMimeTypesDraft.value, settings.value.allowedMimeTypes)
|| uploadRateIpMaxDraft.value !== settings.value.uploadRateIpMax
|| uploadRateTokenMaxDraft.value !== settings.value.uploadRateTokenMax
|| uploadRateWindowMinutesDraft.value !== settings.value.uploadRateWindowMinutes
|| loginRateMaxDraft.value !== settings.value.loginRateMax
|| loginRateWindowMinutesDraft.value !== settings.value.loginRateWindowMinutes
|| preserveOriginalUploadDraft.value !== settings.value.preserveOriginalUpload
}) })
const domainSeparationWouldActivate = computed(() => const domainSeparationWouldActivate = computed(() =>
@@ -221,6 +262,14 @@ function applySettings(data: SettingsResponse) {
capApiEndpointDraft.value = data.capApiEndpoint capApiEndpointDraft.value = data.capApiEndpoint
capSecretDraft.value = data.capSecret capSecretDraft.value = data.capSecret
domainSeparationDraft.value = data.domainSeparation domainSeparationDraft.value = data.domainSeparation
uploadMaxFileSizeMbDraft.value = data.uploadMaxFileSizeMb
allowedMimeTypesDraft.value = [...data.allowedMimeTypes]
uploadRateIpMaxDraft.value = data.uploadRateIpMax
uploadRateTokenMaxDraft.value = data.uploadRateTokenMax
uploadRateWindowMinutesDraft.value = data.uploadRateWindowMinutes
loginRateMaxDraft.value = data.loginRateMax
loginRateWindowMinutesDraft.value = data.loginRateWindowMinutes
preserveOriginalUploadDraft.value = data.preserveOriginalUpload
} }
async function patchSettings(body: Record<string, unknown>) { async function patchSettings(body: Record<string, unknown>) {
@@ -296,6 +345,11 @@ async function performSaveServerSettings() {
savingServer.value = true savingServer.value = true
try { try {
if (allowedMimeTypesDraft.value.length === 0) {
toast.add({ title: t('settings.allowedMimeTypesHint'), color: 'error' })
return
}
await patchSettings({ await patchSettings({
allowedRefererHosts: refererDraft.value, allowedRefererHosts: refererDraft.value,
domainSeparation: domainSeparationDraft.value, domainSeparation: domainSeparationDraft.value,
@@ -308,7 +362,15 @@ async function performSaveServerSettings() {
turnstileSiteKey: turnstileSiteKeyDraft.value, turnstileSiteKey: turnstileSiteKeyDraft.value,
turnstileSecretKey: turnstileSecretKeyDraft.value, turnstileSecretKey: turnstileSecretKeyDraft.value,
capApiEndpoint: capApiEndpointDraft.value, capApiEndpoint: capApiEndpointDraft.value,
capSecret: capSecretDraft.value capSecret: capSecretDraft.value,
uploadMaxFileSizeMb: uploadMaxFileSizeMbDraft.value,
allowedMimeTypes: allowedMimeTypesDraft.value,
uploadRateIpMax: uploadRateIpMaxDraft.value,
uploadRateTokenMax: uploadRateTokenMaxDraft.value,
uploadRateWindowMinutes: uploadRateWindowMinutesDraft.value,
loginRateMax: loginRateMaxDraft.value,
loginRateWindowMinutes: loginRateWindowMinutesDraft.value,
preserveOriginalUpload: preserveOriginalUploadDraft.value
}) })
toast.add({ title: t('settings.saved'), color: 'success' }) toast.add({ title: t('settings.saved'), color: 'success' })
} catch (error: unknown) { } catch (error: unknown) {
@@ -492,7 +554,42 @@ watch(isAdmin, () => {
</SettingsGroup> </SettingsGroup>
</SettingsSection> </SettingsSection>
<SettingsUserPreferencesFields /> <SettingsSection
:title="t('preferences.title')"
:hint="t('settings.uploadLimitsHint')"
>
<SettingsGroup>
<SettingsUploadPolicyFields
v-model:upload-max-file-size-mb="uploadMaxFileSizeMbDraft"
v-model:allowed-mime-types="allowedMimeTypesDraft"
v-model:upload-rate-ip-max="uploadRateIpMaxDraft"
v-model:upload-rate-token-max="uploadRateTokenMaxDraft"
v-model:upload-rate-window-minutes="uploadRateWindowMinutesDraft"
part="upload-preference"
:upload-max-file-size-mb-source="settings.uploadMaxFileSizeMbSource"
:allowed-mime-types-source="settings.allowedMimeTypesSource"
:upload-rate-ip-max-source="settings.uploadRateIpMaxSource"
:upload-rate-token-max-source="settings.uploadRateTokenMaxSource"
:upload-rate-window-minutes-source="settings.uploadRateWindowMinutesSource"
:mime-type-options="settings.mimeTypeOptions"
/>
<SettingsUserPreferencesFields part="upload-preference" />
</SettingsGroup>
</SettingsSection>
<SettingsSection
:title="t('preferences.processing')"
:hint="t('settings.imageProcessingHint')"
>
<SettingsGroup>
<SettingsUploadPolicyFields
v-model:preserve-original-upload="preserveOriginalUploadDraft"
part="image-processing"
:preserve-original-upload-source="settings.preserveOriginalUploadSource"
/>
<SettingsUserPreferencesFields part="image-processing" />
</SettingsGroup>
</SettingsSection>
<SettingsSection :title="t('settings.systemInfo')"> <SettingsSection :title="t('settings.systemInfo')">
<SettingsGroup> <SettingsGroup>
@@ -897,6 +994,21 @@ watch(isAdmin, () => {
</div> </div>
</SettingsGroup> </SettingsGroup>
</SettingsSection> </SettingsSection>
<SettingsSection
:title="t('settings.rateLimits')"
:hint="t('settings.loginRateLimitsHint')"
>
<SettingsGroup>
<SettingsUploadPolicyFields
v-model:login-rate-max="loginRateMaxDraft"
v-model:login-rate-window-minutes="loginRateWindowMinutesDraft"
part="login-rate"
:login-rate-max-source="settings.loginRateMaxSource"
:login-rate-window-minutes-source="settings.loginRateWindowMinutesSource"
/>
</SettingsGroup>
</SettingsSection>
</SettingsPanel> </SettingsPanel>
<div <div
v-else v-else
+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
+6 -6
View File
@@ -12,10 +12,10 @@ All REST calls use:
Auth-Token: YOUR_TOKEN Auth-Token: YOUR_TOKEN
``` ```
| Token type | How to obtain | Notes | | Token | How to obtain | Notes |
| ---------- | ------------- | ----- | | ----- | ------------- | ----- |
| **Global** | Admin **API** page or `API_UPLOAD_TOKEN` env | Env wins and blocks UI regenerate | | **API token** | **API** page after sign-in (one per account, auto-created) | `Auth-Token` header; uploads and tagNames belong to that account |
| **Personal** | Each user’s **API** page | Uploads belong to that user | | **Legacy site token** | `API_UPLOAD_TOKEN` env or old admin setting | Still accepted; uploads belong to the **first admin**. Twikoo form `token` also accepts each user’s API token |
Form uploads may use field `token` (Twikoo protocol). Form uploads may use field `token` (Twikoo protocol).
@@ -31,10 +31,10 @@ Upload one or more images via `image`; also accepts `file`, `files`. Stored unde
curl -X POST "https://admin.example.com/api/images/upload" \ curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \ -H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png" \ -F "image=@./demo.png" \
-F 'tagIds=[1,2]' -F 'tagNames=["blog","screenshot"]'
``` ```
Optional form field `tagIds`: JSON array string or repeated fields. Tags do **not** change storage paths under `images/`. Optional form field `tagNames`: JSON array or comma-separated names. Tags apply to the user bound to the token (**global token cannot tag**). Tags do **not** change storage paths under `images/`.
### 2. List images ### 2. List images
+27
View File
@@ -4,6 +4,33 @@ 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
- Gallery batch tagging and upload token auth (incl. legacy `API_UPLOAD_TOKEN`)
### Improved
- Single API token per account; upload `tagNames` only; tag management sort/mobile layout; API doc table layout
## [1.4.1] — 2026-09-16
### Added
- **Upload policy (Settings → Basic)**: max size, allowed formats, rate limits, preserve originals; env override
- **Upload API**: optional `tagNames` for tagging by name (create or reuse)
### Improved
- Configurable login rate limits; settings layout updates
## [1.4.0] — 2026-09-09 ## [1.4.0] — 2026-09-09
### Added ### Added
+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
+6 -6
View File
@@ -12,10 +12,10 @@ PicHost 提供 REST 接口与 Twikoo 兼容端点。登录后打开顶栏 **API*
Auth-Token: YOUR_TOKEN Auth-Token: YOUR_TOKEN
``` ```
| Token 类型 | 获取方式 | 说明 | | Token | 获取方式 | 说明 |
| ---------- | -------- | ---- | | ----- | -------- | ---- |
| **全局 Token** | 管理员 **API** 页生成;或 `API_UPLOAD_TOKEN` 环境变量 | 环境变量优先且锁定后台重新生成 | | **API Token** | 登录后 **API** 页(每账号一个,自动生成) | 请求头 `Auth-Token`;上传与 tagNames 归属该账号 |
| **个人 Token** | 各用户 **API** 页 | 仅用于该用户脚本,上传归属本人 | | **遗留全站 Token** | 环境变量 `API_UPLOAD_TOKEN` 或旧版后台配置 | 仍可用;上传归属**首个管理员**账号。Twikoo 表单 `token` 亦支持各账号 API Token |
表单上传仍可使用字段 `token`(Twikoo 协议)。 表单上传仍可使用字段 `token`(Twikoo 协议)。
@@ -31,10 +31,10 @@ Auth-Token: YOUR_TOKEN
curl -X POST "https://admin.example.com/api/images/upload" \ curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \ -H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png" \ -F "image=@./demo.png" \
-F 'tagIds=[1,2]' -F 'tagNames=["博客","截图"]'
``` ```
可选表单字段 `tagIds`:JSON 数组字符串(如 `[1,2]`)或重复字段,上传成功后自动打标。存储路径仍为 `images/年/月/id.webp`,**不会**因标签创建子目录。 可选表单字段 `tagNames`:JSON 数组或逗号分隔名称;上传成功后自动打标。存储路径仍为 `images/年/月/id.webp`,**不会**因标签创建子目录。
### 2. 获取图片列表 ### 2. 获取图片列表
+27
View File
@@ -4,6 +4,33 @@ PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [C
--- ---
## [1.4.3] — 2026-10-09
### 新增
- **Telegram 存储后端**:频道/群组存图,直链仍经 PicHost;可选 API 代理
## [1.4.2] — 2026-09-23
### 修复
- 图库批量打标与上传 Token 鉴权(含 `API_UPLOAD_TOKEN` 兼容)
### 改进
- API 页单 Token;上传 `tagNames`;标签管理排序与移动端布局;API 文档表格式
## [1.4.1] — 2026-09-16
### 新增
- **上传策略(设置 → 基础)**:大小、格式、限流、保留原图;环境变量可覆盖
- **上传 API**:可选 `tagNames` 按名称打标(自动创建或复用)
### 改进
- 登录限流可配置;设置页布局调整
## [1.4.0] — 2026-09-09 ## [1.4.0] — 2026-09-09
### 新增 ### 新增
+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**,与环境变量并存时以管理界面为准管理多后端。
+7 -12
View File
@@ -13,8 +13,7 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
| 系统设置(`/settings`) | ✓ | — | | 系统设置(`/settings`) | ✓ | — |
| 开放注册开关 | ✓ | — | | 开放注册开关 | ✓ | — |
| 登录人机验证方式(滑块 / Turnstile / Cap) | ✓ | — | | 登录人机验证方式(滑块 / Turnstile / Cap) | ✓ | — |
| 全局 API Token 管理 | ✓ | — | | API Token(每账号一个) | ✓ | ✓ |
| 个人 API Token | ✓ | ✓ |
| 图库统计概览(含注册用户数) | ✓ | 部分 | | 图库统计概览(含注册用户数) | ✓ | 部分 |
| 标签管理(设置 → 标签管理) | ✓ | — | | 标签管理(设置 → 标签管理) | ✓ | — |
| 操作日志(设置 → 操作日志) | ✓ | — | | 操作日志(设置 → 操作日志) | ✓ | — |
@@ -26,9 +25,8 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
| 场景 | 鉴权方式 | 图片归属 / 可见范围 | | 场景 | 鉴权方式 | 图片归属 / 可见范围 |
| ---- | -------- | ------------------- | | ---- | -------- | ------------------- |
| 网页上传 | Session Cookie | 当前登录用户 | | 网页上传 | Session Cookie | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` 头 | Token 所属用户 | | API 上传 | `Auth-Token` 或表单 `token` | Token 对应账号(遗留 `API_UPLOAD_TOKEN` 归属首个管理员) |
| API 上传 + **全局 Token** | `Auth-Token` 头 | 管理员(`userId` 为空) | | Twikoo `POST /api/index.php` | 表单 `token` | 同上 |
| Twikoo `POST /api/index.php` | 表单 `token` | 与全局 Token 相同 |
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 | | 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 | | 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
@@ -59,14 +57,11 @@ PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)**
- **上传偏好**:管理员在 **设置 → 基础设置** 配置客户端预压缩、自动复制链接等;普通用户可在首页上传卡片背面调整个人偏好 - **上传偏好**:管理员在 **设置 → 基础设置** 配置客户端预压缩、自动复制链接等;普通用户可在首页上传卡片背面调整个人偏好
- **自动删除**:管理员可设全局策略;用户可设个人策略。启用后仅影响 **之后新上传** 的图片 - **自动删除**:管理员可设全局策略;用户可设个人策略。启用后仅影响 **之后新上传** 的图片
## API Token 说明 ## API Token
| 类型 | 谁管理 | 上传归属 | 典型用途 | - **每个账号一个 Token**(含管理员),在 **API** 页查看或重新生成;首次打开会自动创建
| ---- | ------ | -------- | -------- | - 上传、`tagNames` 打标、图库接口均归属 **该 Token 所属账号**
| **全局 Token** | 管理员(API 页) | 管理员账号 | Twikoo、站点级脚本 | - 环境变量 **`API_UPLOAD_TOKEN`**(旧部署)仍可作为鉴权密钥,上传归属 **首个管理员**,见 [环境变量](./configuration.md)
| **个人 Token** | 各用户(API 页) | 该用户 | 个人博客、私有脚本 |
若设置了环境变量 `API_UPLOAD_TOKEN`,则全局 Token 被锁定,见 [环境变量](./configuration.md)。
## 相关 ## 相关
+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 零配置
+45 -6
View File
@@ -196,6 +196,7 @@
"colName": "Tag name", "colName": "Tag name",
"colImageCount": "Images", "colImageCount": "Images",
"colLastUsed": "Last used", "colLastUsed": "Last used",
"colCreatedAt": "Created",
"neverUsed": "Never used", "neverUsed": "Never used",
"colActions": "Actions", "colActions": "Actions",
"mergeIntoAction": "Merge into…", "mergeIntoAction": "Merge into…",
@@ -286,12 +287,12 @@
"back": "Back to upload", "back": "Back to upload",
"processing": "Image processing", "processing": "Image processing",
"clientCompress": "Client-side compression", "clientCompress": "Client-side compression",
"clientCompressHint": "Compress in the browser before upload (server still converts to WebP)", "clientCompressHint": "Compress in the browser before upload. The server may still convert to WebP unless “Preserve originals” is enabled above.",
"autoDeletePrefix": "Auto-delete after", "autoDeletePrefix": "Auto-delete after",
"autoDeleteSuffix": "days", "autoDeleteSuffix": "days",
"autoDeleteHint": "Only affects images uploaded after enabling; existing images are kept", "autoDeleteHint": "Only affects images uploaded after enabling; existing images are kept",
"webpQuality": "WebP quality", "webpQuality": "WebP quality",
"webpQualityHint": "Client compression quality (1–100); requires client-side compression", "webpQualityHint": "Quality for client-side WebP pre-compression; higher values mean better quality and larger files.",
"current": "Current: {n}", "current": "Current: {n}",
"qualityHigh": "High quality", "qualityHigh": "High quality",
"qualityMedium": "Medium quality", "qualityMedium": "Medium quality",
@@ -414,7 +415,31 @@
"envOverride": "Overridden by environment variable; cannot edit in UI", "envOverride": "Overridden by environment variable; cannot edit in UI",
"saved": "Settings saved", "saved": "Settings saved",
"saveFailed": "Failed to save settings", "saveFailed": "Failed to save settings",
"loadFailed": "Failed to load settings" "loadFailed": "Failed to load settings",
"uploadLimits": "Upload limits",
"uploadLimitsHint": "Site-wide upload policy and your personal convenience options. Applies after save.",
"imageProcessingHint": "Site-wide format conversion and personal compression/cleanup.",
"serverPolicy": "Site-wide",
"personalPreference": "Personal",
"uploadRateGroup": "Upload rate limits",
"minutesUnit": "min",
"uploadMaxFileSize": "Max file size",
"uploadMaxFileSizeHint": "Uploads larger than this are rejected (1–100 MB).",
"allowedMimeTypes": "Allowed image formats",
"allowedMimeTypesHint": "Keep at least one format enabled. The server validates file signatures, not just extensions.",
"preserveOriginalUpload": "Preserve originals (disable WebP conversion)",
"preserveOriginalUploadHint": "When enabled, JPEG/PNG/GIF uploads are stored as-is instead of converting to WebP.",
"rateLimits": "Rate limits",
"rateLimitsHint": "Protect against upload abuse and login brute force.",
"loginRateLimitsHint": "Protect the login endpoint from brute-force attempts.",
"uploadRateFootnote": "Returns 429 when exceeded within the window. Token uploads are counted separately.",
"loginRateFootnote": "Returns 429 when exceeded within the window.",
"uploadRateIpMax": "Upload limit (per IP)",
"uploadRateTokenMax": "Upload limit (per token)",
"uploadRateWindowMinutes": "Upload window (minutes)",
"loginRateMax": "Login limit (per IP)",
"loginRateWindowMinutes": "Login window (minutes)",
"rateLimitsFootnote": "Exceeding the limit within the window returns HTTP 429. Token uploads are counted separately."
}, },
"storage": { "storage": {
"title": "Storage", "title": "Storage",
@@ -430,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",
@@ -491,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",
@@ -800,13 +838,13 @@
"pagePlaceholder": "Page" "pagePlaceholder": "Page"
}, },
"api": { "api": {
"tokenTitle": "API Token", "tokenTitle": "API token",
"envOverrideTitle": "Token overridden by environment", "envOverrideTitle": "Token overridden by environment",
"envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.", "envOverrideDesc": "Restart after changing API_UPLOAD_TOKEN; cannot regenerate in UI.",
"personalTitle": "Personal API token", "personalTitle": "Personal API token",
"personalDesc": "For your scripts only; uploads belong to your account.", "personalDesc": "For your scripts only; uploads belong to your account.",
"notConfiguredTitle": "No token configured", "notConfiguredTitle": "No token configured",
"notConfiguredDesc": "Click Regenerate to create a token for Auth-Token on all endpoints below.", "notConfiguredDesc": "A token is created automatically on first visit, or click Regenerate.",
"regenerate": "Regenerate", "regenerate": "Regenerate",
"tokenSourceEnv": "Source: API_UPLOAD_TOKEN environment variable", "tokenSourceEnv": "Source: API_UPLOAD_TOKEN environment variable",
"tokenSourceDb": "Source: generated in app (can regenerate)", "tokenSourceDb": "Source: generated in app (can regenerate)",
@@ -854,7 +892,8 @@
"image": "Primary upload field", "image": "Primary upload field",
"file": "Compatibility field, single file", "file": "Compatibility field, single file",
"files": "Compatibility field, multiple files", "files": "Compatibility field, multiple files",
"token": "Twikoo protocol form token" "token": "Twikoo protocol form token",
"tagNames": "Optional JSON array or comma-separated names; creates or reuses tags"
}, },
"list": { "list": {
"limit": "Page size, default 20, max 100", "limit": "Page size, default 20, max 100",
+43 -5
View File
@@ -196,6 +196,7 @@
"colName": "标签名称", "colName": "标签名称",
"colImageCount": "图片数", "colImageCount": "图片数",
"colLastUsed": "最近使用", "colLastUsed": "最近使用",
"colCreatedAt": "创建时间",
"neverUsed": "从未使用", "neverUsed": "从未使用",
"colActions": "操作", "colActions": "操作",
"mergeIntoAction": "合并到…", "mergeIntoAction": "合并到…",
@@ -286,12 +287,12 @@
"back": "返回上传", "back": "返回上传",
"processing": "图片处理", "processing": "图片处理",
"clientCompress": "客户端预压缩", "clientCompress": "客户端预压缩",
"clientCompressHint": "在上传前于浏览器端压缩图片,减少上传体积(服务端仍会转 WebP)。", "clientCompressHint": "上传前在浏览器端压缩以减小体积;若未开启上方「保留原图」,服务端仍可能转为 WebP。",
"autoDeletePrefix": "自动删除超过", "autoDeletePrefix": "自动删除超过",
"autoDeleteSuffix": "天的图片", "autoDeleteSuffix": "天的图片",
"autoDeleteHint": "自动清理指定天数前上传的图片,仅对开启后新上传的图片生效。", "autoDeleteHint": "自动清理指定天数前上传的图片,仅对开启后新上传的图片生效。",
"webpQuality": "WebP 压缩质量", "webpQuality": "WebP 压缩质量",
"webpQualityHint": "转换为 WebP 格式时的图片质量,数值越高画质越好、体积越大。", "webpQualityHint": "客户端预压缩为 WebP 时的质量;数值越高画质越好、体积越大。",
"current": "当前:{n}", "current": "当前:{n}",
"qualityHigh": "高质量", "qualityHigh": "高质量",
"qualityMedium": "中等质量", "qualityMedium": "中等质量",
@@ -414,7 +415,31 @@
"envOverride": "该项已由环境变量覆盖,无法在后台修改", "envOverride": "该项已由环境变量覆盖,无法在后台修改",
"saved": "服务器配置已保存", "saved": "服务器配置已保存",
"saveFailed": "保存服务器配置失败", "saveFailed": "保存服务器配置失败",
"loadFailed": "加载设置失败" "loadFailed": "加载设置失败",
"uploadLimits": "上传限制",
"uploadLimitsHint": "全站上传策略与个人上传便捷选项,保存后立即生效。",
"imageProcessingHint": "全站格式转换与个人端压缩、清理策略。",
"serverPolicy": "全站策略",
"personalPreference": "个人偏好",
"uploadRateGroup": "上传限流",
"minutesUnit": "分钟",
"uploadMaxFileSize": "单文件大小上限",
"uploadMaxFileSizeHint": "超过此大小的图片将被拒绝上传(1–100 MB)。",
"allowedMimeTypes": "允许的图片格式",
"allowedMimeTypesHint": "至少保留一种格式;服务端会校验文件签名,不仅看扩展名。",
"preserveOriginalUpload": "保留原图(关闭 WebP 转换)",
"preserveOriginalUploadHint": "开启后上传的 JPEG/PNG/GIF 不再转为 WebP,按原格式存储;关闭时可节省空间。",
"rateLimits": "限流",
"rateLimitsHint": "防止恶意刷上传或暴力登录。",
"loginRateLimitsHint": "防止登录接口被暴力尝试。",
"uploadRateFootnote": "窗口期内超过次数将返回 429;带 Token 的上传单独计数。",
"loginRateFootnote": "窗口期内超过次数将返回 429。",
"uploadRateIpMax": "上传限流(每 IP)",
"uploadRateTokenMax": "上传限流(每 Token)",
"uploadRateWindowMinutes": "上传限流窗口(分钟)",
"loginRateMax": "登录限流(每 IP)",
"loginRateWindowMinutes": "登录限流窗口(分钟)",
"rateLimitsFootnote": "窗口期内超过次数将返回 429。带 Token 的上传单独计数。"
}, },
"storage": { "storage": {
"title": "存储设置", "title": "存储设置",
@@ -430,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",
@@ -491,6 +528,7 @@
"supportedR2Desc": "高可用、低成本的对象存储", "supportedR2Desc": "高可用、低成本的对象存储",
"supportedS3Title": "S3 兼容", "supportedS3Title": "S3 兼容",
"supportedS3Desc": "兼容 Amazon S3 协议的存储服务", "supportedS3Desc": "兼容 Amazon S3 协议的存储服务",
"supportedTelegramDesc": "用 Telegram 频道/群组存图,适合轻量与无限容量场景",
"footerTip": "默认存储后端将自动接收所有新上传的图片", "footerTip": "默认存储后端将自动接收所有新上传的图片",
"statTotal": "总量", "statTotal": "总量",
"statUsed": "已用", "statUsed": "已用",
@@ -806,7 +844,7 @@
"personalTitle": "个人 API Token", "personalTitle": "个人 API Token",
"personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。", "personalDesc": "此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。",
"notConfiguredTitle": "尚未配置 Token", "notConfiguredTitle": "尚未配置 Token",
"notConfiguredDesc": "点击「重新生成」创建 Token,用于下方所有接口的 Auth-Token 鉴权。", "notConfiguredDesc": "首次打开会自动创建 Token,或点击「重新生成」。",
"regenerate": "重新生成", "regenerate": "重新生成",
"tokenSourceEnv": "来源:环境变量 API_UPLOAD_TOKEN", "tokenSourceEnv": "来源:环境变量 API_UPLOAD_TOKEN",
"tokenSourceDb": "来源:后台生成(可重新生成)", "tokenSourceDb": "来源:后台生成(可重新生成)",
@@ -855,7 +893,7 @@
"file": "兼容字段,单图上传", "file": "兼容字段,单图上传",
"files": "兼容字段,多图上传", "files": "兼容字段,多图上传",
"token": "Twikoo 协议兼容,表单 token", "token": "Twikoo 协议兼容,表单 token",
"tagIds": "可选,JSON 数组或重复字段,上传后自动打标" "tagNames": "可选,JSON 数组或逗号分隔;名称不存在则创建,存在则复用"
}, },
"list": { "list": {
"limit": "每页条数,默认 20,最大 100", "limit": "每页条数,默认 20,最大 100",
+13 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.4.0", "version": "1.4.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pichost", "name": "pichost",
"version": "1.4.0", "version": "1.4.3",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
@@ -18,7 +18,8 @@
"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",
@@ -19181,6 +19182,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",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.4.0", "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.", "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": {
@@ -53,7 +53,8 @@
"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() ?? '',
+2 -21
View File
@@ -14,6 +14,7 @@ import { getAdminSecret } from '../../utils/env'
import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification' import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification'
import { logActivity } from '../../utils/activity-log' import { logActivity } from '../../utils/activity-log'
import { clientIp, logInfo, logWarn } from '../../utils/logger' import { clientIp, logInfo, logWarn } from '../../utils/logger'
import { checkLoginRateLimit } from '../../utils/rate-limit'
interface LoginBody extends VerificationBody { interface LoginBody extends VerificationBody {
username?: string username?: string
@@ -21,29 +22,9 @@ interface LoginBody extends VerificationBody {
secret?: string secret?: string
} }
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
const MAX_ATTEMPTS = 10
const WINDOW_MS = 15 * 60 * 1000
function checkRateLimit(ip: string): void {
const now = Date.now()
const entry = loginAttempts.get(ip)
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS })
return
}
entry.count += 1
if (entry.count > MAX_ATTEMPTS) {
throw createError({
statusCode: 429,
statusMessage: '登录尝试过于频繁,请稍后再试'
})
}
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const ip = clientIp(event) const ip = clientIp(event)
checkRateLimit(ip) checkLoginRateLimit(ip)
const body = await readBody<LoginBody>(event) const body = await readBody<LoginBody>(event)
await verifyLoginVerification(event, body ?? {}) await verifyLoginVerification(event, body ?? {})
+6 -3
View File
@@ -32,6 +32,9 @@ function validateKeys(keys: string[]): void {
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event) await requireApiOrAdminAuth(event)
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '批量打标需要登录', 401)
}
const body = await readBody<BatchTagsBody>(event) const body = await readBody<BatchTagsBody>(event)
const keys = Array.isArray(body?.keys) const keys = Array.isArray(body?.keys)
@@ -70,10 +73,10 @@ export default defineEventHandler(async (event) => {
try { try {
for (const key of keys) { for (const key of keys) {
if (action === 'add') { if (action === 'add') {
addTagsToImage(key, tagIds, user!.id, user!.role === 'admin') addTagsToImage(key, tagIds, user.id, user.role === 'admin')
} else { } else {
for (const tagId of tagIds) { for (const tagId of tagIds) {
removeTagFromImage(key, tagId, user!.id, user!.role === 'admin') removeTagFromImage(key, tagId, user.id, user.role === 'admin')
} }
} }
updated++ updated++
@@ -88,7 +91,7 @@ export default defineEventHandler(async (event) => {
originalName: action === 'add' originalName: action === 'add'
? `批量打标(${keys.length} 张)` ? `批量打标(${keys.length} 张)`
: `批量移除标签(${keys.length} 张)`, : `批量移除标签(${keys.length} 张)`,
userId: user!.id userId: user.id
}) })
return { success: true, updated } return { success: true, updated }
+12 -19
View File
@@ -12,7 +12,11 @@ import {
} from '../../utils/process-image-upload' } from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { checkUploadRateLimit } from '../../utils/rate-limit' import { checkUploadRateLimit } from '../../utils/rate-limit'
import { parseTagIdsParam } from '../../utils/tags' import { readMultipartFormFieldValues } from '../../utils/multipart-form'
import {
parseTagNamesParam,
resolveUploadTagIds
} from '../../utils/tags'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUploadAuth(event) await requireUploadAuth(event)
@@ -29,26 +33,15 @@ export default defineEventHandler(async (event) => {
checkUploadRateLimit(event, formToken) checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web' const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event) const uploadUserId = await getUploadUserId(event, formToken)
const tagIdsField = formData.find(part => part.name === 'tagIds') const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames')
let uploadTagIds: number[] = [] const uploadTagNames = parseTagNamesParam(tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw)
if (tagIdsField?.data?.length) { const resolvedTags = resolveUploadTagIds(uploadUserId, uploadTagNames)
const raw = new TextDecoder().decode(tagIdsField.data).trim() if ('message' in resolvedTags) {
try { createApiError(event, 'INVALID_REQUEST', resolvedTags.message, 400)
const parsed = JSON.parse(raw) as unknown
uploadTagIds = parseTagIdsParam(parsed)
} catch {
uploadTagIds = parseTagIdsParam(raw)
}
} else {
const repeatFields = formData.filter(part => part.name === 'tagIds' && part.data?.length)
if (repeatFields.length) {
uploadTagIds = parseTagIdsParam(
repeatFields.map(part => new TextDecoder().decode(part.data!).trim())
)
}
} }
const uploadTagIds = resolvedTags.tagIds
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图) // 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter( const fileParts = formData.filter(
+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,
+84
View File
@@ -34,6 +34,16 @@ import {
validateLoginVerificationSettings, validateLoginVerificationSettings,
validateSettingsDomainPatch validateSettingsDomainPatch
} from '../../utils/env' } from '../../utils/env'
import {
normalizeAllowedMimeTypes,
parseLoginRateMax,
parseLoginRateWindowMinutes,
parseUploadMaxFileSizeMb,
parseUploadRateIpMax,
parseUploadRateTokenMax,
parseUploadRateWindowMinutes,
setUploadPolicySettings
} from '../../utils/upload-policy'
interface SettingsPatchBody { interface SettingsPatchBody {
webpQuality?: unknown webpQuality?: unknown
@@ -50,6 +60,14 @@ interface SettingsPatchBody {
turnstileSecretKey?: unknown turnstileSecretKey?: unknown
capApiEndpoint?: unknown capApiEndpoint?: unknown
capSecret?: unknown capSecret?: unknown
uploadMaxFileSizeMb?: unknown
allowedMimeTypes?: unknown
uploadRateIpMax?: unknown
uploadRateTokenMax?: unknown
uploadRateWindowMinutes?: unknown
loginRateMax?: unknown
loginRateWindowMinutes?: unknown
preserveOriginalUpload?: unknown
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -249,6 +267,72 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_CAP_SECRET, nextCapSecret) setSetting(SETTINGS_CAP_SECRET, nextCapSecret)
} }
if (body.uploadMaxFileSizeMb !== undefined) {
const mb = parseUploadMaxFileSizeMb(String(body.uploadMaxFileSizeMb))
if (mb === null) {
createApiError(event, 'INVALID_REQUEST', '单文件大小需为 1–100 MB 的整数', 400)
}
setUploadPolicySettings({ uploadMaxFileSizeMb: mb })
}
if (body.allowedMimeTypes !== undefined) {
const raw = Array.isArray(body.allowedMimeTypes)
? body.allowedMimeTypes.map(item => String(item))
: String(body.allowedMimeTypes).split(',')
const normalized = normalizeAllowedMimeTypes(raw.join(','))
if (normalized.length === 0) {
createApiError(event, 'INVALID_REQUEST', '至少选择一种允许上传的图片格式', 400)
}
setUploadPolicySettings({ allowedMimeTypes: normalized })
}
if (body.uploadRateIpMax !== undefined) {
const value = parseUploadRateIpMax(String(body.uploadRateIpMax))
if (value === null) {
createApiError(event, 'INVALID_REQUEST', '上传 IP 限流次数无效', 400)
}
setUploadPolicySettings({ uploadRateIpMax: value })
}
if (body.uploadRateTokenMax !== undefined) {
const value = parseUploadRateTokenMax(String(body.uploadRateTokenMax))
if (value === null) {
createApiError(event, 'INVALID_REQUEST', '上传 Token 限流次数无效', 400)
}
setUploadPolicySettings({ uploadRateTokenMax: value })
}
if (body.uploadRateWindowMinutes !== undefined) {
const value = parseUploadRateWindowMinutes(String(body.uploadRateWindowMinutes))
if (value === null) {
createApiError(event, 'INVALID_REQUEST', '上传限流窗口无效', 400)
}
setUploadPolicySettings({ uploadRateWindowMinutes: value })
}
if (body.loginRateMax !== undefined) {
const value = parseLoginRateMax(String(body.loginRateMax))
if (value === null) {
createApiError(event, 'INVALID_REQUEST', '登录限流次数无效', 400)
}
setUploadPolicySettings({ loginRateMax: value })
}
if (body.loginRateWindowMinutes !== undefined) {
const value = parseLoginRateWindowMinutes(String(body.loginRateWindowMinutes))
if (value === null) {
createApiError(event, 'INVALID_REQUEST', '登录限流窗口无效', 400)
}
setUploadPolicySettings({ loginRateWindowMinutes: value })
}
if (body.preserveOriginalUpload !== undefined) {
if (typeof body.preserveOriginalUpload !== 'boolean') {
createApiError(event, 'INVALID_REQUEST', '保留原图设置无效', 400)
}
setUploadPolicySettings({ preserveOriginalUpload: body.preserveOriginalUpload })
}
logActivity(event, { logActivity(event, {
action: 'settings', action: 'settings',
key: 'settings/system', key: 'settings/system',
+6 -7
View File
@@ -1,5 +1,5 @@
import { readMultipartFormData } from 'h3' import { readMultipartFormData } from 'h3'
import { verifyUploadTokenValue } from '../../utils/access' import { verifyUploadTokenValue, getUploadUserId } from '../../utils/access'
import { checkUploadRateLimit } from '../../utils/rate-limit' import { checkUploadRateLimit } from '../../utils/rate-limit'
import { import {
detectMimeFromSignature, detectMimeFromSignature,
@@ -9,7 +9,6 @@ import {
mimeMatchesDeclared, mimeMatchesDeclared,
processSingleImageUpload processSingleImageUpload
} from '../../utils/process-image-upload' } from '../../utils/process-image-upload'
import { getApiUploadToken } from '../../utils/env'
/** EasyImages 2.0 兼容上传(Twikoo IMAGE_CDN=easyimage) */ /** EasyImages 2.0 兼容上传(Twikoo IMAGE_CDN=easyimage) */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -29,15 +28,14 @@ export default defineEventHandler(async (event) => {
: '' : ''
if (!devBypass) { if (!devBypass) {
if (!getApiUploadToken(event)) { if (!token || !verifyUploadTokenValue(event, token)) {
return easyImageError(500, '服务端未配置 API_UPLOAD_TOKEN')
}
if (!verifyUploadTokenValue(event, token)) {
return easyImageError(401, 'token 错误或无效') return easyImageError(401, 'token 错误或无效')
} }
checkUploadRateLimit(event, token) checkUploadRateLimit(event, token)
} }
const uploadUserId = await getUploadUserId(event, token)
const imagePart = formData.find( const imagePart = formData.find(
part => part.name === 'image' && part.data?.length part => part.name === 'image' && part.data?.length
) )
@@ -60,7 +58,8 @@ export default defineEventHandler(async (event) => {
const result = await processSingleImageUpload(event, { const result = await processSingleImageUpload(event, {
bytes, bytes,
filename: imagePart.filename, filename: imagePart.filename,
source: 'api' source: 'api',
userId: uploadUserId
}) })
if ('error' in result) { if ('error' in result) {
+58 -11
View File
@@ -1,5 +1,5 @@
import type { H3Event } from 'h3' import type { H3Event } from 'h3'
import { findUserByApiToken } from './db' import { findUserByApiToken, getPrimaryAdminUserId } from './db'
import { getAdminSecret, getApiUploadToken } from './env' import { getAdminSecret, getApiUploadToken } from './env'
import { createApiError } from './api-error' import { createApiError } from './api-error'
import { import {
@@ -85,10 +85,29 @@ export function verifyGlobalApiUploadToken(event: H3Event): boolean {
return timingSafeEqual(token, expected) return timingSafeEqual(token, expected)
} }
export function resolveUserIdFromApiToken(event: H3Event): number | null { function resolveUserIdFromTokenValue(token: string): number | null {
const token = extractApiToken(event) const trimmed = token.trim()
if (!token) return null if (!trimmed) return null
return findUserByApiToken(token)?.id ?? null return findUserByApiToken(trimmed)?.id ?? null
}
/** 从 Auth-Token 请求头或表单 token 解析个人 API Token 对应用户(非全站 Token) */
export function resolveUserIdFromApiToken(
event: H3Event,
extraToken?: string
): number | null {
const headerToken = extractApiToken(event)
if (headerToken) {
const fromHeader = resolveUserIdFromTokenValue(headerToken)
if (fromHeader !== null) return fromHeader
}
if (extraToken) {
const fromForm = resolveUserIdFromTokenValue(extraToken)
if (fromForm !== null) return fromForm
}
return null
} }
export function verifyApiUploadToken(event: H3Event): boolean { export function verifyApiUploadToken(event: H3Event): boolean {
@@ -222,20 +241,48 @@ export function resolveActivitySource(event: H3Event): 'api' | 'web' {
return verifyApiUploadToken(event) ? 'api' : 'web' return verifyApiUploadToken(event) ? 'api' : 'web'
} }
export async function getUploadUserId(event: H3Event): Promise<number | null> { function resolveUploadOwnerFromGlobalToken(
event: H3Event,
formToken?: string
): number | null {
const expected = getApiUploadToken(event)
if (!expected) return null
const headerToken = extractApiToken(event)
if (headerToken && timingSafeEqual(headerToken, expected)) {
return getPrimaryAdminUserId()
}
const trimmedForm = formToken?.trim()
if (trimmedForm && timingSafeEqual(trimmedForm, expected)) {
return getPrimaryAdminUserId()
}
return null
}
export async function getUploadUserId(
event: H3Event,
formToken?: string
): Promise<number | null> {
if (devBypass(event)) { if (devBypass(event)) {
return null return null
} }
const userIdFromToken = resolveUserIdFromApiToken(event) const userIdFromToken = resolveUserIdFromApiToken(event, formToken)
if (userIdFromToken !== null) { if (userIdFromToken !== null) {
return userIdFromToken return userIdFromToken
} }
if (verifyGlobalApiUploadToken(event)) { const user = await getCurrentUser(event)
return null if (user) {
return user.id
} }
const user = await getCurrentUser(event) const globalOwnerId = resolveUploadOwnerFromGlobalToken(event, formToken)
return user?.id ?? null if (globalOwnerId !== null) {
return globalOwnerId
}
return null
} }
+7 -5
View File
@@ -95,15 +95,17 @@ describe('activity log smoke', () => {
await new Promise(resolve => setTimeout(resolve, 50)) await new Promise(resolve => setTimeout(resolve, 50))
}) })
it('opens the project data database without throwing', async () => { it('lists activity logs on a fresh database without throwing', async () => {
const projectData = join(process.cwd(), 'data') useTempDataDir()
process.env.DATA_DIR = projectData
const { getDb, listActivityLogs, closeDb } = await loadDbModule() const { getDb, listActivityLogs, closeDb } = await loadDbModule()
getDb() getDb()
const { ensureStorageSchema } = await import('./storage-backends')
ensureStorageSchema()
const result = listActivityLogs({ limit: 5, page: 1 }) const result = listActivityLogs({ limit: 5, page: 1 })
expect(result.page).toBeGreaterThan(0) expect(result.page).toBe(1)
expect(Array.isArray(result.items)).toBe(true) expect(result.items).toEqual([])
closeDb() closeDb()
await new Promise(resolve => setTimeout(resolve, 50)) await new Promise(resolve => setTimeout(resolve, 50))
}) })
+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,
+8
View File
@@ -414,6 +414,14 @@ export function listAdminUserIds(): Set<number> {
return new Set(rows.map(row => row.id)) return new Set(rows.map(row => row.id))
} }
/** 首个管理员(按 id),用于遗留全站 Token 上传归属 */
export function getPrimaryAdminUserId(): number | null {
const row = getDb().prepare(`
SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1
`).get() as { id: number } | undefined
return row?.id ?? null
}
export function setUserAutoDeleteDays(userId: number, days: number): void { export function setUserAutoDeleteDays(userId: number, days: number): void {
setUserAutoDeletePolicy(userId, days) setUserAutoDeletePolicy(userId, days)
} }
+2
View File
@@ -18,6 +18,7 @@ import {
SETTINGS_CAP_SECRET, SETTINGS_CAP_SECRET,
isAllowRegistration isAllowRegistration
} from './db' } from './db'
import { getUploadPolicySettingsPayload } from './upload-policy'
export function getRuntimeEnv( export function getRuntimeEnv(
event: H3Event, event: H3Event,
@@ -694,6 +695,7 @@ export function getSettingsPayload(event: H3Event) {
capApiEndpoint: getCapApiEndpointConfigured(event), capApiEndpoint: getCapApiEndpointConfigured(event),
capSecret: getCapSecret(event), capSecret: getCapSecret(event),
appVersion, appVersion,
...getUploadPolicySettingsPayload(),
env: { env: {
webpQuality, webpQuality,
refererConfigured: allowedRefererHosts.length > 0, refererConfigured: allowedRefererHosts.length > 0,
+6 -3
View File
@@ -34,7 +34,8 @@ describe('shouldBlockImageHostRequest', () => {
imageHost, imageHost,
requestHost: imageHost, requestHost: imageHost,
method: 'GET', method: 'GET',
pathname: '/settings' pathname: '/settings',
hideFolder: false
})).toBe(true) })).toBe(true)
}) })
@@ -45,7 +46,8 @@ describe('shouldBlockImageHostRequest', () => {
imageHost, imageHost,
requestHost: imageHost, requestHost: imageHost,
method: 'GET', method: 'GET',
pathname: '/images/2026/08/demo.webp' pathname: '/images/2026/08/demo.webp',
hideFolder: false
})).toBe(false) })).toBe(false)
}) })
@@ -93,7 +95,8 @@ describe('shouldBlockSiteHostImageRequest', () => {
siteHost, siteHost,
requestHost: siteHost, requestHost: siteHost,
method: 'GET', method: 'GET',
pathname: '/images/2026/08/demo.webp' pathname: '/images/2026/08/demo.webp',
hideFolder: false
})).toBe(true) })).toBe(true)
}) })
+2 -2
View File
@@ -1,5 +1,5 @@
import { getStorageBackendRow } from './storage-backends' import { getStorageBackendRow } from './storage-backends'
import { ALLOWED_MIME_TYPES } from './constants' import { getAllowedMimeTypesList } from './upload-policy'
import { parseTagIdsParam, type TagFilterInput } from './tags' import { parseTagIdsParam, type TagFilterInput } from './tags'
export function readBackendIdQuery( export function readBackendIdQuery(
@@ -15,7 +15,7 @@ export function readContentTypeQuery(
): string | undefined | null { ): string | undefined | null {
const raw = typeof query.contentType === 'string' ? query.contentType.trim() : '' const raw = typeof query.contentType === 'string' ? query.contentType.trim() : ''
if (!raw || raw === 'all') return undefined if (!raw || raw === 'all') return undefined
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(raw)) return null if (!(getAllowedMimeTypesList() as readonly string[]).includes(raw)) return null
return raw return raw
} }
+30
View File
@@ -0,0 +1,30 @@
import type { MultiPartData } from 'h3'
export function readMultipartFormFieldValues(
formData: MultiPartData[],
fieldName: string
): string[] {
const field = formData.find(part => part.name === fieldName)
if (field?.data?.length) {
const raw = new TextDecoder().decode(field.data).trim()
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
return parsed.map(item => String(item).trim()).filter(Boolean)
}
} catch {
// fall through to comma-separated parsing
}
return raw.split(',').map(item => item.trim()).filter(Boolean)
}
const repeatFields = formData.filter(
part => part.name === fieldName && part.data?.length
)
if (!repeatFields.length) return []
return repeatFields
.map(part => new TextDecoder().decode(part.data!).trim())
.filter(Boolean)
}
+17 -9
View File
@@ -2,10 +2,6 @@ import sharp from 'sharp'
import type { H3Event } from 'h3' import type { H3Event } from 'h3'
import type { ImageItem, UploadErrorItem } from '~/types/image' import type { ImageItem, UploadErrorItem } from '~/types/image'
import type { AllowedMimeType } from './constants' import type { AllowedMimeType } from './constants'
import {
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE
} from './constants'
import { import {
detectMimeFromSignature, detectMimeFromSignature,
isAllowedMimeType, isAllowedMimeType,
@@ -15,6 +11,12 @@ import type { LogSource } from './db'
import { generateImageKey } from './image-key' 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 {
formatEffectiveMaxFileSizeMessage,
getEffectiveMaxFileSizeBytes,
isMimeTypeAllowed,
isPreserveOriginalUpload
} from './upload-policy'
import { logActivity } from './activity-log' import { logActivity } from './activity-log'
import { attachTagsAfterUpload, getTagsForImageKeys } from './tags' import { attachTagsAfterUpload, getTagsForImageKeys } from './tags'
import { putImage } from './storage' import { putImage } from './storage'
@@ -71,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 > MAX_FILE_SIZE) { if (bytes.byteLength > getEffectiveMaxFileSizeBytes()) {
return { return {
error: { error: {
name: originalName, name: originalName,
error: { error: {
code: 'FILE_TOO_LARGE', code: 'FILE_TOO_LARGE',
message: '图片大小不能超过 10 MB' message: formatEffectiveMaxFileSizeMessage()
} }
} }
} }
@@ -96,7 +98,7 @@ export async function processSingleImageUpload(
} }
} }
if (!ALLOWED_MIME_TYPES.includes(detectedMime)) { if (!isMimeTypeAllowed(detectedMime)) {
return { return {
error: { error: {
name: originalName, name: originalName,
@@ -120,7 +122,9 @@ export async function processSingleImageUpload(
} }
} }
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event)) const compressed = isPreserveOriginalUpload()
? { bytes, mime: detectedMime }
: await compressToWebp(bytes, detectedMime, getWebpQuality(event))
const uploadedAt = new Date().toISOString() const uploadedAt = new Date().toISOString()
const layout = getStorageLayout(event) const layout = getStorageLayout(event)
@@ -140,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
} }
} }
} }
+5 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { checkUploadRateLimit } from './rate-limit' import { checkUploadRateLimit } from './rate-limit'
import { getUploadRateLimitSettings } from './upload-policy'
vi.mock('./logger', () => ({ vi.mock('./logger', () => ({
clientIp: (event: { __ip?: string }) => event.__ip ?? '127.0.0.1' clientIp: (event: { __ip?: string }) => event.__ip ?? '127.0.0.1'
@@ -27,7 +28,8 @@ describe('checkUploadRateLimit', () => {
it('limits repeated uploads by IP', () => { it('limits repeated uploads by IP', () => {
const event = mockEvent('10.0.0.3') const event = mockEvent('10.0.0.3')
for (let i = 0; i < 60; i++) { const { ipMax } = getUploadRateLimitSettings()
for (let i = 0; i < ipMax; i++) {
checkUploadRateLimit(event) checkUploadRateLimit(event)
} }
expect(() => checkUploadRateLimit(event)).toThrow() expect(() => checkUploadRateLimit(event)).toThrow()
@@ -35,7 +37,8 @@ describe('checkUploadRateLimit', () => {
it('limits repeated API token uploads', () => { it('limits repeated API token uploads', () => {
const token = 'token-a' const token = 'token-a'
for (let i = 0; i < 120; i++) { const { tokenMax } = getUploadRateLimitSettings()
for (let i = 0; i < tokenMax; i++) {
checkUploadRateLimit(mockEvent(`10.0.${i % 200}`), token) checkUploadRateLimit(mockEvent(`10.0.${i % 200}`), token)
} }
expect(() => checkUploadRateLimit(mockEvent('10.0.250'), token)).toThrow() expect(() => checkUploadRateLimit(mockEvent('10.0.250'), token)).toThrow()
+25 -8
View File
@@ -1,10 +1,7 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { createError, getHeader, type H3Event } from 'h3' import { createError, getHeader, type H3Event } from 'h3'
import { clientIp } from './logger' import { clientIp } from './logger'
import { getUploadRateLimitSettings, getLoginRateLimitSettings } from './upload-policy'
const UPLOAD_IP_MAX = 60
const UPLOAD_TOKEN_MAX = 120
const UPLOAD_WINDOW_MS = 15 * 60 * 1000
const stores = new Map<string, Map<string, { count: number, resetAt: number }>>() const stores = new Map<string, Map<string, { count: number, resetAt: number }>>()
@@ -43,12 +40,13 @@ function assertRateLimit(
} }
export function checkUploadRateLimit(event: H3Event, apiToken?: string): void { export function checkUploadRateLimit(event: H3Event, apiToken?: string): void {
const { ipMax, tokenMax, windowMs } = getUploadRateLimitSettings()
const ip = clientIp(event) const ip = clientIp(event)
assertRateLimit( assertRateLimit(
'upload-ip', 'upload-ip',
ip, ip,
UPLOAD_IP_MAX, ipMax,
UPLOAD_WINDOW_MS, windowMs,
'上传过于频繁,请稍后再试' '上传过于频繁,请稍后再试'
) )
@@ -60,8 +58,27 @@ export function checkUploadRateLimit(event: H3Event, apiToken?: string): void {
assertRateLimit( assertRateLimit(
'upload-token', 'upload-token',
hash, hash,
UPLOAD_TOKEN_MAX, tokenMax,
UPLOAD_WINDOW_MS, windowMs,
'上传过于频繁,请稍后再试' '上传过于频繁,请稍后再试'
) )
} }
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
export function checkLoginRateLimit(ip: string): void {
const { max, windowMs } = getLoginRateLimitSettings()
const now = Date.now()
const entry = loginAttempts.get(ip)
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + windowMs })
return
}
entry.count += 1
if (entry.count > max) {
throw createError({
statusCode: 429,
statusMessage: '登录尝试过于频繁,请稍后再试'
})
}
}
+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)
}
+297
View File
@@ -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]))
})
})
+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
+27 -1
View File
@@ -4,7 +4,9 @@ import {
isValidTagColor, isValidTagColor,
isValidTagName, isValidTagName,
normalizeTagName, normalizeTagName,
parseTagIdsParam parseTagIdsParam,
parseTagNamesParam,
resolveUploadTagIds
} from './tags' } from './tags'
describe('normalizeTagName', () => { describe('normalizeTagName', () => {
@@ -51,6 +53,30 @@ describe('parseTagIdsParam', () => {
}) })
}) })
describe('parseTagNamesParam', () => {
it('parses comma-separated and json array values', () => {
expect(parseTagNamesParam('博客, 截图')).toEqual(['博客', '截图'])
expect(parseTagNamesParam('["工作","截图"]')).toEqual(['工作', '截图'])
expect(parseTagNamesParam(['A', 'B'])).toEqual(['A', 'B'])
})
it('deduplicates normalized names', () => {
expect(parseTagNamesParam('博客, 博客 ,截图')).toEqual(['博客', '截图'])
})
})
describe('resolveUploadTagIds', () => {
it('returns empty when no tags requested', () => {
expect(resolveUploadTagIds(1, [])).toEqual({ tagIds: [] })
})
it('requires user when tag names are requested', () => {
expect(resolveUploadTagIds(null, ['博客'])).toMatchObject({
message: expect.stringContaining('API Token')
})
})
})
describe('buildTagFilterSql', () => { describe('buildTagFilterSql', () => {
it('returns empty sql without filter', () => { it('returns empty sql without filter', () => {
const params: Array<string | number> = [] const params: Array<string | number> = []
+187 -24
View File
@@ -1,5 +1,6 @@
import { getDb } from './db' import { getDb } from './db'
import { getImageIndexRow } from './image-index' import { getImageIndexRow } from './image-index'
import { toCanonicalImageKey } from './image-key'
export const TAG_COLOR_PRESETS = [ export const TAG_COLOR_PRESETS = [
'#22c55e', '#22c55e',
@@ -310,17 +311,65 @@ function assertTagsOwnedByUser(userId: number, tagIds: number[]): void {
} }
} }
function resolveImageOwnerUserId(key: string): number | null { function resolveIndexedImageKey(key: string): string | null {
const row = getImageIndexRow(key) if (getImageIndexRow(key)) return key
return row?.user_id ?? null const canonical = toCanonicalImageKey(key)
if (canonical && getImageIndexRow(canonical)) return canonical
return null
}
/** 打标时标签归属的用户:无上传者的索引图用操作者自己的标签 */
function resolveTagOwnerUserId(
imageOwnerUserId: number | null,
actorUserId: number
): number {
return imageOwnerUserId ?? actorUserId
}
/**
* 管理员给他人图片打标时,将操作者选中的 tagId 按名称映射到图片所有者的标签。
*/
export function resolveTagIdsForImageTagging(
actorUserId: number,
tagIds: number[],
imageOwnerUserId: number | null,
isAdmin: boolean
): number[] {
const tagOwnerUserId = resolveTagOwnerUserId(imageOwnerUserId, actorUserId)
const unique = [...new Set(tagIds)]
if (!unique.length) return []
if (tagOwnerUserId === actorUserId || !isAdmin) {
assertTagsOwnedByUser(tagOwnerUserId, unique)
return unique
}
const resolved: number[] = []
for (const tagId of unique) {
const row = getTagById(tagId)
if (!row) {
throw new Error('TAG_NOT_FOUND')
}
if (row.user_id === tagOwnerUserId) {
resolved.push(tagId)
continue
}
if (row.user_id === actorUserId) {
resolved.push(findOrCreateTagByName(tagOwnerUserId, row.name).id)
continue
}
throw new Error('TAG_NOT_FOUND')
}
return [...new Set(resolved)]
} }
export function assertCanTagImage(key: string, actorUserId: number, isAdmin: boolean): void { export function assertCanTagImage(key: string, actorUserId: number, isAdmin: boolean): void {
const ownerId = resolveImageOwnerUserId(key) const indexedKey = resolveIndexedImageKey(key)
if (ownerId == null) { if (!indexedKey) {
throw new Error('IMAGE_NOT_FOUND') throw new Error('IMAGE_NOT_FOUND')
} }
if (!isAdmin && ownerId !== actorUserId) { const ownerId = getImageIndexRow(indexedKey)!.user_id
if (!isAdmin && (ownerId === null || ownerId !== actorUserId)) {
throw new Error('FORBIDDEN') throw new Error('FORBIDDEN')
} }
} }
@@ -332,19 +381,27 @@ export function addTagsToImage(
isAdmin: boolean isAdmin: boolean
): TagItem[] { ): TagItem[] {
ensureTagSchema() ensureTagSchema()
assertCanTagImage(key, actorUserId, isAdmin) const indexedKey = resolveIndexedImageKey(key)
const ownerId = resolveImageOwnerUserId(key)! if (!indexedKey) {
const uniqueTagIds = [...new Set(tagIds)] throw new Error('IMAGE_NOT_FOUND')
assertTagsOwnedByUser(ownerId, uniqueTagIds) }
assertCanTagImage(indexedKey, actorUserId, isAdmin)
const imageOwnerUserId = getImageIndexRow(indexedKey)!.user_id
const uniqueTagIds = resolveTagIdsForImageTagging(
actorUserId,
tagIds,
imageOwnerUserId,
isAdmin
)
const insert = getDb().prepare(` const insert = getDb().prepare(`
INSERT OR IGNORE INTO image_tags (image_key, tag_id) VALUES (?, ?) INSERT OR IGNORE INTO image_tags (image_key, tag_id) VALUES (?, ?)
`) `)
for (const tagId of uniqueTagIds) { for (const tagId of uniqueTagIds) {
insert.run(key, tagId) insert.run(indexedKey, tagId)
} }
return getTagsForImageKeys([key]).get(key) ?? [] return getTagsForImageKeys([indexedKey]).get(indexedKey) ?? []
} }
export function removeTagFromImage( export function removeTagFromImage(
@@ -354,15 +411,27 @@ export function removeTagFromImage(
isAdmin: boolean isAdmin: boolean
): TagItem[] { ): TagItem[] {
ensureTagSchema() ensureTagSchema()
assertCanTagImage(key, actorUserId, isAdmin) const indexedKey = resolveIndexedImageKey(key)
const ownerId = resolveImageOwnerUserId(key)! if (!indexedKey) {
assertTagsOwnedByUser(ownerId, [tagId]) throw new Error('IMAGE_NOT_FOUND')
}
assertCanTagImage(indexedKey, actorUserId, isAdmin)
const imageOwnerUserId = getImageIndexRow(indexedKey)!.user_id
const [resolvedTagId] = resolveTagIdsForImageTagging(
actorUserId,
[tagId],
imageOwnerUserId,
isAdmin
)
if (resolvedTagId == null) {
return getTagsForImageKeys([indexedKey]).get(indexedKey) ?? []
}
getDb().prepare(` getDb().prepare(`
DELETE FROM image_tags WHERE image_key = ? AND tag_id = ? DELETE FROM image_tags WHERE image_key = ? AND tag_id = ?
`).run(key, tagId) `).run(indexedKey, resolvedTagId)
return getTagsForImageKeys([key]).get(key) ?? [] return getTagsForImageKeys([indexedKey]).get(indexedKey) ?? []
} }
export function setImageTags( export function setImageTags(
@@ -372,20 +441,28 @@ export function setImageTags(
isAdmin: boolean isAdmin: boolean
): TagItem[] { ): TagItem[] {
ensureTagSchema() ensureTagSchema()
assertCanTagImage(key, actorUserId, isAdmin) const indexedKey = resolveIndexedImageKey(key)
const ownerId = resolveImageOwnerUserId(key)! if (!indexedKey) {
const uniqueTagIds = [...new Set(tagIds)] throw new Error('IMAGE_NOT_FOUND')
assertTagsOwnedByUser(ownerId, uniqueTagIds) }
assertCanTagImage(indexedKey, actorUserId, isAdmin)
const imageOwnerUserId = getImageIndexRow(indexedKey)!.user_id
const uniqueTagIds = resolveTagIdsForImageTagging(
actorUserId,
tagIds,
imageOwnerUserId,
isAdmin
)
const db = getDb() const db = getDb()
db.exec('BEGIN') db.exec('BEGIN')
try { try {
db.prepare('DELETE FROM image_tags WHERE image_key = ?').run(key) db.prepare('DELETE FROM image_tags WHERE image_key = ?').run(indexedKey)
const insert = db.prepare(` const insert = db.prepare(`
INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?) INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)
`) `)
for (const tagId of uniqueTagIds) { for (const tagId of uniqueTagIds) {
insert.run(key, tagId) insert.run(indexedKey, tagId)
} }
db.exec('COMMIT') db.exec('COMMIT')
} catch (error) { } catch (error) {
@@ -393,7 +470,7 @@ export function setImageTags(
throw error throw error
} }
return getTagsForImageKeys([key]).get(key) ?? [] return getTagsForImageKeys([indexedKey]).get(indexedKey) ?? []
} }
export function attachTagsAfterUpload( export function attachTagsAfterUpload(
@@ -471,3 +548,89 @@ export function parseTagIdsParam(raw: unknown): number[] {
} }
return [...new Set(ids)] return [...new Set(ids)]
} }
export function parseTagNamesParam(raw: unknown): string[] {
if (raw == null || raw === '') return []
let parts: string[]
if (Array.isArray(raw)) {
parts = raw.flatMap(item => String(item).split(','))
} else {
const str = String(raw).trim()
if (str.startsWith('[')) {
try {
const parsed = JSON.parse(str) as unknown
parts = Array.isArray(parsed)
? parsed.map(item => String(item))
: str.split(',')
} catch {
parts = str.split(',')
}
} else {
parts = str.split(',')
}
}
const names: string[] = []
for (const part of parts) {
const normalized = normalizeTagName(part)
if (normalized) names.push(normalized)
}
return [...new Set(names)]
}
export function findOrCreateTagByName(userId: number, name: string): TagRow {
const normalized = normalizeTagName(name)
if (!isValidTagName(normalized)) {
throw new Error('INVALID_TAG_NAME')
}
const existing = findTagByName(userId, normalized)
if (existing) return existing
const created = createTag(userId, normalized)
const row = getTagById(created.id)
if (!row) {
throw new Error('TAG_CREATE_FAILED')
}
return row
}
export function resolveTagIdsFromNames(userId: number, names: readonly string[]): number[] {
const ids: number[] = []
for (const name of names) {
ids.push(findOrCreateTagByName(userId, name).id)
}
return [...new Set(ids)]
}
export function resolveUploadTagIds(
userId: number | null,
tagNames: string[]
): { tagIds: number[] } | { message: string } {
if (!tagNames.length) {
return { tagIds: [] }
}
if (userId == null) {
return {
message: '使用 tagNames 时请登录或通过 Auth-Token / 表单 token 提供有效 API Token'
}
}
try {
return { tagIds: resolveTagIdsFromNames(userId, tagNames) }
} catch (error) {
if (error instanceof Error) {
switch (error.message) {
case 'INVALID_TAG_NAME':
return { message: '标签名称无效' }
case 'TAG_LIMIT_REACHED':
return { message: '标签数量已达上限' }
default:
break
}
}
throw error
}
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_UPLOAD_MAX_FILE_SIZE_MB,
normalizeAllowedMimeTypes,
parseLoginRateMax,
parsePreserveOriginalUpload,
parseUploadMaxFileSizeMb,
parseUploadRateIpMax,
serializeAllowedMimeTypes,
serializePreserveOriginalUpload
} from './upload-policy'
describe('upload-policy parsers', () => {
it('parses upload max file size mb', () => {
expect(parseUploadMaxFileSizeMb('10')).toBe(10)
expect(parseUploadMaxFileSizeMb('0')).toBeNull()
expect(parseUploadMaxFileSizeMb('101')).toBeNull()
})
it('normalizes allowed mime types', () => {
expect(normalizeAllowedMimeTypes('image/png, image/jpeg, image/png')).toEqual([
'image/png',
'image/jpeg'
])
expect(normalizeAllowedMimeTypes('image/bmp')).toEqual([])
})
it('serializes allowed mime types in canonical order', () => {
expect(serializeAllowedMimeTypes(['image/webp', 'image/jpeg'])).toBe('image/jpeg,image/webp')
})
it('parses rate limit values', () => {
expect(parseUploadRateIpMax('60')).toBe(60)
expect(parseLoginRateMax('10')).toBe(10)
expect(parseUploadRateIpMax('0')).toBeNull()
})
it('defaults max file size mb constant matches legacy 10MB', () => {
expect(DEFAULT_UPLOAD_MAX_FILE_SIZE_MB).toBe(10)
})
it('parses preserve original upload flag', () => {
expect(parsePreserveOriginalUpload('true')).toBe(true)
expect(parsePreserveOriginalUpload('0')).toBe(false)
expect(parsePreserveOriginalUpload('')).toBeNull()
expect(serializePreserveOriginalUpload(true)).toBe('true')
})
})
+371
View File
@@ -0,0 +1,371 @@
import {
ALLOWED_MIME_TYPES,
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'
export const SETTINGS_UPLOAD_RATE_IP_MAX = 'upload_rate_ip_max'
export const SETTINGS_UPLOAD_RATE_TOKEN_MAX = 'upload_rate_token_max'
export const SETTINGS_UPLOAD_RATE_WINDOW_MINUTES = 'upload_rate_window_minutes'
export const SETTINGS_LOGIN_RATE_MAX = 'login_rate_max'
export const SETTINGS_LOGIN_RATE_WINDOW_MINUTES = 'login_rate_window_minutes'
export const SETTINGS_PRESERVE_ORIGINAL_UPLOAD = 'preserve_original_upload'
export const DEFAULT_UPLOAD_MAX_FILE_SIZE_MB = 10
export const MIN_UPLOAD_MAX_FILE_SIZE_MB = 1
export const MAX_UPLOAD_MAX_FILE_SIZE_MB = 100
export const DEFAULT_UPLOAD_RATE_IP_MAX = 60
export const DEFAULT_UPLOAD_RATE_TOKEN_MAX = 120
export const DEFAULT_UPLOAD_RATE_WINDOW_MINUTES = 15
export const MIN_UPLOAD_RATE_MAX = 1
export const MAX_UPLOAD_RATE_IP_MAX = 1000
export const MAX_UPLOAD_RATE_TOKEN_MAX = 5000
export const MIN_RATE_WINDOW_MINUTES = 1
export const MAX_RATE_WINDOW_MINUTES = 1440
export const DEFAULT_LOGIN_RATE_MAX = 10
export const DEFAULT_LOGIN_RATE_WINDOW_MINUTES = 15
export const MAX_LOGIN_RATE_MAX = 100
export const ALL_MIME_TYPE_OPTIONS = [
{ mime: 'image/jpeg', label: 'JPEG' },
{ mime: 'image/png', label: 'PNG' },
{ mime: 'image/webp', label: 'WebP' },
{ mime: 'image/gif', label: 'GIF' },
{ mime: 'image/svg+xml', label: 'SVG' },
{ mime: 'image/x-icon', label: 'ICO' }
] as const satisfies ReadonlyArray<{ mime: AllowedMimeType, label: string }>
export type PolicySource = 'env' | 'db' | 'default'
function readEnv(name: string): string {
try {
return process.env[name]?.trim() ?? ''
} catch {
return ''
}
}
function parsePositiveInt(raw: string, min: number, max: number): number | null {
const trimmed = raw.trim()
if (!trimmed) return null
const value = Number(trimmed)
if (!Number.isFinite(value) || !Number.isInteger(value)) return null
if (value < min || value > max) return null
return value
}
export function parseUploadMaxFileSizeMb(raw: string): number | null {
return parsePositiveInt(raw, MIN_UPLOAD_MAX_FILE_SIZE_MB, MAX_UPLOAD_MAX_FILE_SIZE_MB)
}
export function parseUploadRateIpMax(raw: string): number | null {
return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_UPLOAD_RATE_IP_MAX)
}
export function parseUploadRateTokenMax(raw: string): number | null {
return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_UPLOAD_RATE_TOKEN_MAX)
}
export function parseUploadRateWindowMinutes(raw: string): number | null {
return parsePositiveInt(raw, MIN_RATE_WINDOW_MINUTES, MAX_RATE_WINDOW_MINUTES)
}
export function parseLoginRateMax(raw: string): number | null {
return parsePositiveInt(raw, MIN_UPLOAD_RATE_MAX, MAX_LOGIN_RATE_MAX)
}
export function parseLoginRateWindowMinutes(raw: string): number | null {
return parsePositiveInt(raw, MIN_RATE_WINDOW_MINUTES, MAX_RATE_WINDOW_MINUTES)
}
export function parsePreserveOriginalUpload(raw: string): boolean | null {
const trimmed = raw.trim().toLowerCase()
if (!trimmed) return null
if (trimmed === 'true' || trimmed === '1' || trimmed === 'yes') return true
if (trimmed === 'false' || trimmed === '0' || trimmed === 'no') return false
return null
}
export function serializePreserveOriginalUpload(value: boolean): string {
return value ? 'true' : 'false'
}
export function normalizeAllowedMimeTypes(raw: string): AllowedMimeType[] {
const allowedSet = new Set<string>(ALLOWED_MIME_TYPES)
const seen = new Set<string>()
const result: AllowedMimeType[] = []
for (const part of raw.split(',')) {
const mime = part.trim().toLowerCase()
if (!mime || seen.has(mime) || !allowedSet.has(mime)) continue
seen.add(mime)
result.push(mime as AllowedMimeType)
}
return result
}
export function serializeAllowedMimeTypes(mimes: readonly string[]): string {
const normalized = new Set(normalizeAllowedMimeTypes(mimes.join(',')))
return ALL_MIME_TYPE_OPTIONS
.map(option => option.mime)
.filter(mime => normalized.has(mime))
.join(',')
}
function getUploadMaxFileSizeMbSource(): PolicySource {
if (getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB) !== null) return 'db'
if (parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB')) !== null) return 'env'
return 'default'
}
function getAllowedMimeTypesSource(): PolicySource {
if (getSetting(SETTINGS_ALLOWED_MIME_TYPES) !== null) return 'db'
if (normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')).length > 0) return 'env'
return 'default'
}
function getUploadRateIpMaxSource(): PolicySource {
if (getSetting(SETTINGS_UPLOAD_RATE_IP_MAX) !== null) return 'db'
if (parseUploadRateIpMax(readEnv('UPLOAD_RATE_IP_MAX')) !== null) return 'env'
return 'default'
}
function getUploadRateTokenMaxSource(): PolicySource {
if (getSetting(SETTINGS_UPLOAD_RATE_TOKEN_MAX) !== null) return 'db'
if (parseUploadRateTokenMax(readEnv('UPLOAD_RATE_TOKEN_MAX')) !== null) return 'env'
return 'default'
}
function getUploadRateWindowMinutesSource(): PolicySource {
if (getSetting(SETTINGS_UPLOAD_RATE_WINDOW_MINUTES) !== null) return 'db'
if (parseUploadRateWindowMinutes(readEnv('UPLOAD_RATE_WINDOW_MINUTES')) !== null) return 'env'
return 'default'
}
function getLoginRateMaxSource(): PolicySource {
if (getSetting(SETTINGS_LOGIN_RATE_MAX) !== null) return 'db'
if (parseLoginRateMax(readEnv('LOGIN_RATE_MAX')) !== null) return 'env'
return 'default'
}
function getLoginRateWindowMinutesSource(): PolicySource {
if (getSetting(SETTINGS_LOGIN_RATE_WINDOW_MINUTES) !== null) return 'db'
if (parseLoginRateWindowMinutes(readEnv('LOGIN_RATE_WINDOW_MINUTES')) !== null) return 'env'
return 'default'
}
function getPreserveOriginalUploadSource(): PolicySource {
if (getSetting(SETTINGS_PRESERVE_ORIGINAL_UPLOAD) !== null) return 'db'
if (parsePreserveOriginalUpload(readEnv('PRESERVE_ORIGINAL_UPLOAD')) !== null) return 'env'
return 'default'
}
export function getUploadMaxFileSizeMb(): number {
const dbRaw = getSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB)
if (dbRaw !== null) {
const parsed = parseUploadMaxFileSizeMb(dbRaw)
if (parsed !== null) return parsed
}
const fromEnv = parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB'))
if (fromEnv !== null) return fromEnv
return DEFAULT_UPLOAD_MAX_FILE_SIZE_MB
}
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) {
const parsed = normalizeAllowedMimeTypes(dbRaw)
if (parsed.length > 0) return parsed
}
const fromEnv = normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES'))
if (fromEnv.length > 0) return fromEnv
return [...ALLOWED_MIME_TYPES]
}
export function isMimeTypeAllowed(mime: string): mime is AllowedMimeType {
return getAllowedMimeTypesList().includes(mime as AllowedMimeType)
}
export function isPreserveOriginalUpload(): boolean {
const dbRaw = getSetting(SETTINGS_PRESERVE_ORIGINAL_UPLOAD)
if (dbRaw !== null) {
const parsed = parsePreserveOriginalUpload(dbRaw)
if (parsed !== null) return parsed
}
const fromEnv = parsePreserveOriginalUpload(readEnv('PRESERVE_ORIGINAL_UPLOAD'))
if (fromEnv !== null) return fromEnv
return false
}
export function getUploadRateLimitSettings(): {
ipMax: number
tokenMax: number
windowMs: number
} {
const ipMax = (() => {
const dbRaw = getSetting(SETTINGS_UPLOAD_RATE_IP_MAX)
if (dbRaw !== null) {
const parsed = parseUploadRateIpMax(dbRaw)
if (parsed !== null) return parsed
}
return parseUploadRateIpMax(readEnv('UPLOAD_RATE_IP_MAX')) ?? DEFAULT_UPLOAD_RATE_IP_MAX
})()
const tokenMax = (() => {
const dbRaw = getSetting(SETTINGS_UPLOAD_RATE_TOKEN_MAX)
if (dbRaw !== null) {
const parsed = parseUploadRateTokenMax(dbRaw)
if (parsed !== null) return parsed
}
return parseUploadRateTokenMax(readEnv('UPLOAD_RATE_TOKEN_MAX')) ?? DEFAULT_UPLOAD_RATE_TOKEN_MAX
})()
const windowMinutes = (() => {
const dbRaw = getSetting(SETTINGS_UPLOAD_RATE_WINDOW_MINUTES)
if (dbRaw !== null) {
const parsed = parseUploadRateWindowMinutes(dbRaw)
if (parsed !== null) return parsed
}
return parseUploadRateWindowMinutes(readEnv('UPLOAD_RATE_WINDOW_MINUTES'))
?? DEFAULT_UPLOAD_RATE_WINDOW_MINUTES
})()
return {
ipMax,
tokenMax,
windowMs: windowMinutes * 60 * 1000
}
}
export function getLoginRateLimitSettings(): {
max: number
windowMs: number
} {
const max = (() => {
const dbRaw = getSetting(SETTINGS_LOGIN_RATE_MAX)
if (dbRaw !== null) {
const parsed = parseLoginRateMax(dbRaw)
if (parsed !== null) return parsed
}
return parseLoginRateMax(readEnv('LOGIN_RATE_MAX')) ?? DEFAULT_LOGIN_RATE_MAX
})()
const windowMinutes = (() => {
const dbRaw = getSetting(SETTINGS_LOGIN_RATE_WINDOW_MINUTES)
if (dbRaw !== null) {
const parsed = parseLoginRateWindowMinutes(dbRaw)
if (parsed !== null) return parsed
}
return parseLoginRateWindowMinutes(readEnv('LOGIN_RATE_WINDOW_MINUTES'))
?? DEFAULT_LOGIN_RATE_WINDOW_MINUTES
})()
return {
max,
windowMs: windowMinutes * 60 * 1000
}
}
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(),
uploadMaxFileSizeMbSource: getUploadMaxFileSizeMbSource(),
uploadMaxFileSizeMbEnvFallback: parseUploadMaxFileSizeMb(readEnv('UPLOAD_MAX_FILE_SIZE_MB'))
?? DEFAULT_UPLOAD_MAX_FILE_SIZE_MB,
allowedMimeTypes: getAllowedMimeTypesList(),
allowedMimeTypesSource: getAllowedMimeTypesSource(),
allowedMimeTypesEnvFallback: normalizeAllowedMimeTypes(readEnv('ALLOWED_MIME_TYPES')),
uploadRateIpMax: getUploadRateLimitSettings().ipMax,
uploadRateIpMaxSource: getUploadRateIpMaxSource(),
uploadRateTokenMax: getUploadRateLimitSettings().tokenMax,
uploadRateTokenMaxSource: getUploadRateTokenMaxSource(),
uploadRateWindowMinutes: getUploadRateLimitSettings().windowMs / (60 * 1000),
uploadRateWindowMinutesSource: getUploadRateWindowMinutesSource(),
loginRateMax: getLoginRateLimitSettings().max,
loginRateMaxSource: getLoginRateMaxSource(),
loginRateWindowMinutes: getLoginRateLimitSettings().windowMs / (60 * 1000),
loginRateWindowMinutesSource: getLoginRateWindowMinutesSource(),
preserveOriginalUpload: isPreserveOriginalUpload(),
preserveOriginalUploadSource: getPreserveOriginalUploadSource(),
mimeTypeOptions: ALL_MIME_TYPE_OPTIONS.map(option => ({
mime: option.mime,
label: option.label
}))
}
}
export function setUploadPolicySettings(input: {
uploadMaxFileSizeMb?: number
allowedMimeTypes?: readonly string[]
uploadRateIpMax?: number
uploadRateTokenMax?: number
uploadRateWindowMinutes?: number
loginRateMax?: number
loginRateWindowMinutes?: number
preserveOriginalUpload?: boolean
}): void {
if (input.uploadMaxFileSizeMb !== undefined) {
setSetting(SETTINGS_UPLOAD_MAX_FILE_SIZE_MB, String(input.uploadMaxFileSizeMb))
}
if (input.allowedMimeTypes !== undefined) {
setSetting(SETTINGS_ALLOWED_MIME_TYPES, serializeAllowedMimeTypes(input.allowedMimeTypes))
}
if (input.uploadRateIpMax !== undefined) {
setSetting(SETTINGS_UPLOAD_RATE_IP_MAX, String(input.uploadRateIpMax))
}
if (input.uploadRateTokenMax !== undefined) {
setSetting(SETTINGS_UPLOAD_RATE_TOKEN_MAX, String(input.uploadRateTokenMax))
}
if (input.uploadRateWindowMinutes !== undefined) {
setSetting(SETTINGS_UPLOAD_RATE_WINDOW_MINUTES, String(input.uploadRateWindowMinutes))
}
if (input.loginRateMax !== undefined) {
setSetting(SETTINGS_LOGIN_RATE_MAX, String(input.loginRateMax))
}
if (input.loginRateWindowMinutes !== undefined) {
setSetting(SETTINGS_LOGIN_RATE_WINDOW_MINUTES, String(input.loginRateWindowMinutes))
}
if (input.preserveOriginalUpload !== undefined) {
setSetting(
SETTINGS_PRESERVE_ORIGINAL_UPLOAD,
serializePreserveOriginalUpload(input.preserveOriginalUpload)
)
}
}