diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3da5a1d..221a990 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
+## [1.1.0] - 2026-08-26
+
+### 新增
+
+- S3 兼容对象存储后端(R2 / AWS S3 / 腾讯云 COS / 阿里云 OSS)
+- `storage_backends` + `images` 索引表,列表/搜索/统计改查 SQLite
+- 混合直链:`proxy`(默认,PicHost 代理)与 `public`(302 到 CDN)
+- 管理员 `/storage` 存储管理页:多后端卡片、用量条、添加/编辑/删除云存储实例
+- 可选环境变量覆盖存储配置(`STORAGE_BACKEND`、`S3_*`)
+
+### 改进
+
+- 启动时自动将本地磁盘图片扫描迁入 `images` 索引(幂等)
+- 上传/删除/直链/auto-delete 按 `backend_id` 路由到对应后端
+
## [1.0.4] - 2026-08-25
### 新增
@@ -68,8 +83,10 @@
### 路线图
-- **v1.1.0**(计划中):S3、Cloudflare R2 及更多对象存储后端
+- **v1.1.0**(当前 `main`):S3、Cloudflare R2 等多后端对象存储
+[1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0
+[1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4
[1.0.3]: https://github.com/O96u/PicHost/releases/tag/v1.0.3
[1.0.2]: https://github.com/O96u/PicHost/releases/tag/v1.0.2
[1.0.1]: https://github.com/O96u/PicHost/releases/tag/v1.0.1
diff --git a/README.en.md b/README.en.md
index 775c218..1d7ed18 100644
--- a/README.en.md
+++ b/README.en.md
@@ -6,7 +6,7 @@
Lightweight personal image hosting · Simple uploads, clean management
-Self-hosted / Multi-user / Docker / API / Twikoo · Zero-config setup
+Self-hosted / Multi-user / Docker / API / Twikoo · Local disk or object storage
@@ -17,6 +17,7 @@
+ Branches ·
Quick Start ·
Screenshots ·
Features ·
@@ -27,26 +28,43 @@
---
+## Branches
+
+| Branch | Notes |
+| ------ | ----- |
+| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.0** — local disk + multi-backend object storage (R2 / COS / OSS / AWS), [`/storage`](docs/README.md) UI, hybrid URLs |
+| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively |
+
+Use **main** for the full product and multiple cloud backends.
+
+If you **only use Cloudflare R2**, check out the dedicated branch:
+
+```bash
+git clone https://github.com/O96u/PicHost.git
+cd PicHost
+git checkout cloudflare
+```
+
+---
+
## Screenshots
-| Login | Upload |
-| :----------------------------------: | :------------------------------------: |
-|  |  |
+| Upload | Storage |
+| :------------------------------------: | :--------------------------------: |
+|  |  |
-| Upload preferences | Stats & gallery |
-| :----------------------------------------------: | :----------------------------------: |
-|  |  |
-
-| Settings | |
-| :----------------------------------------: | :-: |
-|  | |
+| Stats & gallery | Settings |
+| :----------------------------------: | :--------------------------------: |
+|  |  |
## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL
- **Multi-user** — username/password login; optional registration; users see only their images
-- **Upload preferences** (card flip UI): client-side compression, auto-copy links, per-user auto-delete
+- **Upload preferences** (home card flip): client-side compression, auto-copy links, per-user auto-delete
+- **Multi-backend storage**: local disk + S3-compatible (Cloudflare R2 / Tencent COS / Alibaba OSS / AWS S3); admins manage backends at **Storage** (`/storage`)
+- **Hybrid URLs**: `proxy` (same-origin via PicHost) or `public` (302 to CDN / bucket URL)
- **Gallery** — browse, search, batch delete; admin sees uploader badges
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count
- **API** — global token (admin) + per-user tokens; in-app cURL snippets
@@ -89,10 +107,6 @@ npm run dev
Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`.
-### Legacy migration
-
-If you only had `ADMIN_SECRET` and no user table, log in with the secret once and follow the migration wizard.
-
## Tech Stack
| Layer | Technology |
@@ -101,25 +115,10 @@ If you only had `ADMIN_SECRET` and no user table, log in with the secret once an
| Backend | Nitro (Node.js), SQLite |
| Images | sharp (WebP transcoding & compression) |
| Auth | Session cookie, scrypt password hashing, API tokens |
-| Storage | Local disk (`DATA_DIR`) |
+| Storage | Local disk + S3-compatible backends (R2 / COS / OSS / AWS); `images` index in SQLite |
| Deploy | Docker (amd64 / arm64), docker compose |
| Tests | Vitest |
-## Environment variables
-
-| Variable | Description |
-| ----------------------- | --------------------------------------------------------------------------- |
-| `API_UPLOAD_TOKEN` | Global upload token (`Auth-Token` header); can be generated in the API page |
-| `ALLOWED_REFERER_HOSTS` | Hotlink allowlist (comma-separated hostnames) |
-| `IMAGE_BASE_URL` | Public base URL for image links |
-| `WEBP_QUALITY` | Server WebP quality 1–100, default 80 |
-| `AUTO_DELETE_DAYS` | Global auto-delete days (0 = off); admin + orphan images only |
-| `DATA_DIR` | Data directory, default `/data` (container) or `./data` (local) |
-| `ADMIN_SECRET` | Legacy v1.0 migration only |
-| `DEV_BYPASS_ACCESS` | Dev only — bypass auth (never in production) |
-
-Environment variables override database settings. See [`.env.example`](.env.example).
-
## Users & permissions
| Scenario | Auth | Ownership / visibility |
@@ -137,13 +136,13 @@ Regular users are limited to the `images/` folder; custom `folder` requires admi
```
/data
-├── pichost.db # SQLite: users, sessions, settings, logs
-├── images/ # Default upload folder
+├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
+├── images/ # Default upload folder (when using local backend)
├── blog/ # Example custom folder
└── twikoo/ # Twikoo comment images
```
-Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. Back up the entire `/data` directory.
+Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. With cloud backends, blobs live in the bucket; SQLite `images` tracks `backend_id` and `key`. Back up all of `/data` plus bucket data.
## API overview
@@ -194,11 +193,12 @@ npm test # Unit tests
| Version | Plan |
| ---------- | -------------------------------------------------------------- |
-| **v1.0.3** | CI and Docker multi-arch build fixes (current) |
+| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI (current `main`) |
+| **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration |
+| **v1.0.3** | CI and Docker multi-arch build fixes |
| **v1.0.2** | i18n, mobile menus, stats/upload UI, Docker password reset |
| **v1.0.1** | Settings version display, mobile upload preferences layout fix |
| **v1.0.0** | Local storage, multi-user, API, Twikoo |
-| **v1.1.0** | S3, Cloudflare R2, and more object storage backends |
## License
diff --git a/README.md b/README.md
index f5c6bca..921f1fc 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
个人轻量图床 · 图片放好,管理也要顺手
-自托管图床 / 多用户 / Docker / API / Twikoo · 零配置引导
+自托管图床 / 多用户 / Docker / API / Twikoo · 本地磁盘或对象存储
@@ -17,6 +17,7 @@
+ 分支 ·
快速开始 ·
预览 ·
特性 ·
@@ -27,26 +28,43 @@
---
+## 分支
+
+| 分支 | 说明 |
+| ---- | ---- |
+| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.0** 主线:本地磁盘 + 多对象存储后端(R2 / COS / OSS / AWS)、[`/storage`](docs/README.md) 存储管理、混合直链 |
+| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
+
+日常使用、需要管理多种存储后端,请直接用 **main**。
+
+若图床**只接 Cloudflare R2**、希望更简化的 R2 专用版本,可切换分支:
+
+```bash
+git clone https://github.com/O96u/PicHost.git
+cd PicHost
+git checkout cloudflare
+```
+
+---
+
## 界面预览
-| 登录 | 上传 |
-| :---------------------------------: | :----------------------------------: |
-|  |  |
+| 上传 | 存储管理 |
+| :-----------------------------------: | :-------------------------------------: |
+|  |  |
-| 上传偏好 | 统计与图库 |
-| :-------------------------------------------: | :---------------------------------: |
-|  |  |
-
-| 系统设置 | |
-| :------------------------------------: | :-: |
-|  | |
+| 统计与图库 | 系统设置 |
+| :---------------------------------: | :-------------------------------------: |
+|  |  |
## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片
-- **上传偏好**(卡片翻转设置):客户端预压缩、自动复制链接、按用户自动删除
+- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除
+- **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端
+- **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址)
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
@@ -94,10 +112,6 @@ npm run dev
访问 `http://localhost:3000/setup` 创建管理员;数据目录默认 `./data`。
-### 遗留部署迁移
-
-若旧版仅配置了 `ADMIN_SECRET`、尚无用户表,可用密钥登录一次,Web 引导迁移为账号密码。
-
## 技术栈
| 层级 | 技术 |
@@ -106,25 +120,10 @@ npm run dev
| 后端 | Nitro(Node.js)、SQLite |
| 图片处理 | sharp(WebP 转码与压缩) |
| 鉴权 | Session Cookie、scrypt 密码哈希、API Token |
-| 存储 | 本地磁盘(`DATA_DIR`) |
+| 存储 | 本地磁盘 + S3 兼容后端(R2 / COS / OSS / AWS);`images` 索引在 SQLite |
| 部署 | Docker(amd64 / arm64)、docker compose |
| 测试 | Vitest |
-## 环境变量
-
-| 变量 | 说明 |
-| ----------------------- | ------------------------------------------------------------ |
-| `API_UPLOAD_TOKEN` | 全局上传 Token(`Auth-Token` 头);未配置可在后台 API 页生成 |
-| `ALLOWED_REFERER_HOSTS` | 防盗链白名单(逗号分隔 hostname) |
-| `IMAGE_BASE_URL` | 图片直链公网域名(反代时建议填写) |
-| `WEBP_QUALITY` | 服务端 WebP 质量 1–100,默认 80 |
-| `AUTO_DELETE_DAYS` | 全局自动删除天数(0 关闭);仅影响管理员及无归属历史图 |
-| `DATA_DIR` | 数据目录,默认 `/data`(容器)或 `./data`(本地) |
-| `ADMIN_SECRET` | 仅遗留 v1.0 迁移,新安装不需要 |
-| `DEV_BYPASS_ACCESS` | 开发用,跳过登录(生产勿开) |
-
-环境变量优先级高于后台设置。详见 [`.env.example`](.env.example)。
-
## 用户与权限
| 场景 | 鉴权 | 图片归属 / 可见范围 |
@@ -142,13 +141,13 @@ npm run dev
```
/data
-├── pichost.db # SQLite:用户、会话、设置、操作日志
-├── images/ # 默认上传目录
+├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
+├── images/ # 默认上传目录(本地后端时)
├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片
```
-路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。备份整个 `/data` 即可。
+路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。云存储时文件在对应桶内,SQLite `images` 表记录 `backend_id` 与 `key`。备份请包含整个 `/data` 与云桶数据。
## API 概览
@@ -197,13 +196,14 @@ npm test # 单元测试
## 路线图
-| 版本 | 计划 |
+| 版本 | 说明 |
| ---------- | ------------------------------------------------- |
-| **v1.0.3** | CI 与 Docker 多架构构建修复(当前) |
+| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页(当前 main) |
+| **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 |
+| **v1.0.3** | CI 与 Docker 多架构构建修复 |
| **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复 |
| **v1.0.0** | 本地存储、多用户、API、Twikoo |
-| **v1.1.0** | S3、Cloudflare R2 等对象存储后端 |
## License
diff --git a/app/components/AppShell.vue b/app/components/AppShell.vue
index 8a8a92e..53bc43d 100644
--- a/app/components/AppShell.vue
+++ b/app/components/AppShell.vue
@@ -16,44 +16,35 @@ const navItems = computed(() => {
{ label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' }
]
if (isAdmin.value) {
+ items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
}
return items
})
-const accountMenuItems = computed(() => {
- const items: DropdownMenuItem[] = [
- {
- label: t('nav.changePassword'),
- icon: 'i-lucide-key-round',
- to: '/account/password'
- }
- ]
-
- if (isAdmin.value) {
- items.push({
- label: t('nav.systemSettings'),
- icon: 'i-lucide-settings',
- to: '/settings'
- })
- }
-
- items.push({
+const accountMenuItems = computed(() => [
+ {
+ label: t('nav.changePassword'),
+ icon: 'i-lucide-key-round',
+ to: '/account/password'
+ },
+ {
label: t('nav.logout'),
icon: 'i-lucide-log-out',
onSelect() {
void handleLogout()
}
- })
-
- return items
-})
+ }
+])
const desktopUserMenuItems = computed(() => [
accountMenuItems.value
])
function isNavActive(to: string) {
+ if (to === '/settings') {
+ return route.path === '/settings'
+ }
return route.path.startsWith(to)
}
diff --git a/app/components/MobileUserMenu.vue b/app/components/MobileUserMenu.vue
index 8f3fb1d..e450fea 100644
--- a/app/components/MobileUserMenu.vue
+++ b/app/components/MobileUserMenu.vue
@@ -1,7 +1,7 @@
+
+
+
+
+
+
+
+
+
+
+ {{ backend.name }}
+
+
+ {{ typeLabel }}
+
+ · {{ bucketLine }}
+
+
+
+
+
+
+ {{ t('storage.badgeEnabled') }}
+
+
+ {{ t('storage.badgeDefault') }}
+
+
+ {{ t('storage.badgeDisabled') }}
+
+
+
+
+
+
+
+ {{ t('storage.servingMode') }}: {{ servingLabel }}
+
+
+
+
+
+
+
+
+
diff --git a/app/components/storage/StorageBackendForm.vue b/app/components/storage/StorageBackendForm.vue
new file mode 100644
index 0000000..2ebfc9b
--- /dev/null
+++ b/app/components/storage/StorageBackendForm.vue
@@ -0,0 +1,330 @@
+
+
+
+
+
diff --git a/app/components/storage/StorageUsageBar.vue b/app/components/storage/StorageUsageBar.vue
new file mode 100644
index 0000000..c722e1d
--- /dev/null
+++ b/app/components/storage/StorageUsageBar.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+ {{ $t('storage.statTotal') }}
+
+
+ {{ formatOptional(capacity.totalBytes) }}
+
+
+
+
+ {{ $t('storage.statUsed') }}
+
+
+ {{ formatFileSize(displayUsedBytes) }}
+
+
+
+
+ {{ $t('storage.statFree') }}
+
+
+ {{ formatOptional(capacity.freeBytes) }}
+
+
+
+
+
+
+ {{ $t('storage.usageRate') }}
+
+ {{ displayPercent }}%
+
+
+
+
+
+
diff --git a/app/composables/useFileSize.ts b/app/composables/useFileSize.ts
index 7793e4d..cd28a95 100644
--- a/app/composables/useFileSize.ts
+++ b/app/composables/useFileSize.ts
@@ -1,10 +1,55 @@
-export function useFileSize() {
- function formatFileSize(bytes: number): string {
- if (!Number.isFinite(bytes) || bytes < 0) return '-'
- if (bytes < 1024) return `${bytes} B`
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
- return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
+export const QUOTA_UNITS = ['KB', 'MB', 'GB', 'TB'] as const
+export type QuotaUnit = typeof QUOTA_UNITS[number]
+
+export function formatFileSize(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes < 0) return '-'
+ if (bytes === 0) return '0 B'
+ if (bytes < 1024) return `${bytes} B`
+
+ const units = ['KB', 'MB', 'GB', 'TB'] as const
+ let value = bytes / 1024
+ let unitIndex = 0
+
+ while (unitIndex < units.length - 1 && value >= 1024) {
+ value /= 1024
+ unitIndex++
}
- return { formatFileSize }
+ const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2
+ return `${value.toFixed(decimals)} ${units[unitIndex]}`
+}
+
+export function bytesToQuotaInput(bytes: number | null): { value: string, unit: QuotaUnit } {
+ if (!bytes || bytes <= 0) {
+ return { value: '', unit: 'GB' }
+ }
+
+ let value = bytes / 1024
+ let unitIndex = 0
+
+ while (unitIndex < QUOTA_UNITS.length - 1 && value >= 1024) {
+ value /= 1024
+ unitIndex++
+ }
+
+ const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2
+ const rounded = Number(value.toFixed(decimals))
+ const unit: QuotaUnit = QUOTA_UNITS[unitIndex] ?? 'GB'
+ return { value: String(rounded), unit }
+}
+
+export function quotaToBytes(value: string | number, unit: QuotaUnit): number | null {
+ const trimmed = String(value ?? '').trim()
+ if (!trimmed) return null
+ const amount = Number(trimmed)
+ if (!Number.isFinite(amount) || amount <= 0) return null
+
+ const unitIndex = QUOTA_UNITS.indexOf(unit)
+ if (unitIndex < 0) return null
+
+ return Math.round(amount * 1024 ** (unitIndex + 1))
+}
+
+export function useFileSize() {
+ return { formatFileSize, bytesToQuotaInput, quotaToBytes, QUOTA_UNITS }
}
diff --git a/app/pages/settings.vue b/app/pages/settings/index.vue
similarity index 100%
rename from app/pages/settings.vue
rename to app/pages/settings/index.vue
diff --git a/app/pages/storage.vue b/app/pages/storage.vue
new file mode 100644
index 0000000..69ea852
--- /dev/null
+++ b/app/pages/storage.vue
@@ -0,0 +1,349 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('storage.pageTitle') }}
+
+
+ {{ t('storage.pageSubtitle') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('storage.switchHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formMode === 'create' ? t('storage.addBackend') : t('storage.editBackend') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('storage.deleteConfirmTitle') }}
+
+
+ {{ t('storage.deleteConfirmBody', { name: deleteTarget?.name ?? '' }) }}
+
+
+ { deleteOpen = false }"
+ />
+
+
+
+
+
+
+
diff --git a/app/types/storage.ts b/app/types/storage.ts
new file mode 100644
index 0000000..93d3077
--- /dev/null
+++ b/app/types/storage.ts
@@ -0,0 +1,39 @@
+export type ServingMode = 'proxy' | 'public'
+
+export interface StorageCapacity {
+ totalBytes: number | null
+ usedBytes: number
+ freeBytes: number | null
+ percent: number | null
+ source: 'disk' | 'quota'
+}
+
+export interface StorageBackendItem {
+ id: string
+ name: string
+ type: 'local' | 's3'
+ config: {
+ endpoint?: string
+ region?: string
+ bucket?: string
+ prefix?: string
+ forcePathStyle?: boolean
+ }
+ secretsMasked: {
+ accessKeyId?: string
+ secretAccessKey?: string
+ }
+ servingMode: ServingMode
+ publicUrl: string
+ quotaBytes: number | null
+ enabled: boolean
+ isDefault: boolean
+ usage: { count: number, bytes: number }
+ capacity: StorageCapacity
+}
+
+export interface StorageListResponse {
+ backends: StorageBackendItem[]
+ activeBackendId: string
+ envOverride: boolean
+}
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..4d6b4b2
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,53 @@
+# PicHost 文档资源
+
+本目录主要存放 README 引用的界面截图与分支说明。
+
+## 分支
+
+| 分支 | 说明 |
+| ---- | ---- |
+| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.0** 主线:本地磁盘 + 多对象存储(R2 / COS / OSS / AWS)、`/storage` 管理页、混合直链 |
+| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署场景,配置更聚焦 R2 |
+
+- 需要多种云存储或完整管理界面 → 使用 **main**
+- 图床只接 **Cloudflare R2** → 可选用 **`cloudflare`** 分支
+
+## 截图一览
+
+以下为 **main(v1.1.0)** 界面截图。
+
+| 文件 | 说明 |
+| ---- | ---- |
+| `screenshots/upload.png` | 首页上传区(顶栏含 API / 统计 / 存储 / 设置) |
+| `screenshots/storage.png` | 存储管理(`/storage`):多后端卡片、用量、设默认 |
+| `screenshots/stats.png` | 统计与图库 |
+| `screenshots/settings.png` | 系统设置(上传偏好 + 系统设置) |
+| `screenshots/preferences.png` | 首页上传卡片翻转后的上传偏好 |
+| `screenshots/login.png` | 登录页 |
+
+## 相关页面路由(main / v1.1.0)
+
+| 路由 | 权限 | 说明 |
+| ---- | ---- | ---- |
+| `/` | 登录用户 | 上传与图库入口 |
+| `/storage` | 管理员 | 存储后端管理 |
+| `/settings` | 管理员 | 系统设置 |
+| `/stats` | 登录用户 | 统计 |
+| `/api` | 登录用户 | API 文档与 Token |
+
+管理员顶栏:**API** · **统计** · **存储** · **设置**。用户下拉菜单仅 **修改密码**、**退出登录**。
+
+## Cloudflare R2 配置对照
+
+**main**:后台 **存储 → 添加存储 → Cloudflare R2** 预设。
+
+**cloudflare 分支**(R2 专用):通常以环境变量为主:
+
+| 配置项 | 值 |
+| ------ | -- |
+| `STORAGE_BACKEND` | `s3` |
+| `S3_ENDPOINT` | `https://.r2.cloudflarestorage.com` |
+| `S3_REGION` | `auto` |
+| `S3_BUCKET` | 桶名 |
+| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | R2 API Token |
+| 直链 | 默认 PicHost 代理;桶绑域名后可改公开 URL |
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
index db66787..9564598 100644
Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ
diff --git a/docs/screenshots/storage.png b/docs/screenshots/storage.png
new file mode 100644
index 0000000..e73b193
Binary files /dev/null and b/docs/screenshots/storage.png differ
diff --git a/docs/screenshots/upload.png b/docs/screenshots/upload.png
index 9090e18..5cfe6f1 100644
Binary files a/docs/screenshots/upload.png and b/docs/screenshots/upload.png differ
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index 4fb715b..bff476d 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -10,6 +10,7 @@
"cancel": "Cancel",
"copy": "Copy",
"delete": "Delete",
+ "edit": "Edit",
"search": "Search",
"refresh": "Refresh",
"back": "Back",
@@ -17,12 +18,14 @@
"notConfigured": "(not configured)",
"countImages": "{count} images",
"itemsUnit": "items",
- "imagesUnit": "images"
+ "imagesUnit": "images",
+ "more": "More"
},
"nav": {
"api": "API",
"stats": "Stats",
"settings": "Settings",
+ "storage": "Storage",
"menu": "Menu",
"language": "Language",
"appearance": "Appearance",
@@ -191,6 +194,86 @@
"saveFailed": "Failed to save settings",
"loadFailed": "Failed to load settings"
},
+ "storage": {
+ "title": "Storage",
+ "subtitle": "New uploads go to the active backend; existing images keep their backend",
+ "activeBackend": "Active backend",
+ "backendLocal": "Local disk",
+ "backendCloud": "Cloud storage",
+ "backendS3": "Cloud storage",
+ "switchHint": "Switching only affects new uploads",
+ "provider": "Preset",
+ "providerR2": "Cloudflare R2",
+ "providerCos": "Tencent COS",
+ "providerOss": "Alibaba OSS",
+ "providerAws": "AWS S3",
+ "providerCustom": "Custom",
+ "endpoint": "Endpoint",
+ "region": "Region",
+ "bucket": "Bucket",
+ "prefix": "Key prefix (optional)",
+ "prefixPlaceholder": "e.g. pichost",
+ "forcePathStyle": "Force path-style access",
+ "accessKey": "Access Key",
+ "secretKey": "Secret Key",
+ "servingMode": "Serving mode",
+ "servingProxy": "Proxy (same origin)",
+ "servingPublic": "Public URL (302 to CDN)",
+ "servingHint": "In public mode, configure referer protection on CDN/bucket",
+ "publicUrl": "Public base URL",
+ "publicUrlPlaceholder": "https://cdn.example.com",
+ "testConnection": "Test connection",
+ "testSuccess": "Connection successful",
+ "testFailed": "Connection failed",
+ "saved": "Storage settings saved",
+ "saveFailed": "Failed to save storage settings",
+ "loadFailed": "Failed to load storage settings",
+ "envOverride": "Storage is overridden by environment variables",
+ "pageTitle": "Storage management",
+ "pageSubtitle": "Manage backends; new uploads go to the default backend",
+ "addBackend": "Add storage",
+ "editBackend": "Edit storage",
+ "formCreateHint": "Configure cloud storage; set as default after saving to receive new uploads",
+ "formEditHint": "Update connection settings; leave keys blank to keep existing secrets",
+ "sectionBasic": "Basic",
+ "sectionConnection": "Connection",
+ "sectionCredentials": "Credentials",
+ "sectionAccess": "Access",
+ "sectionQuota": "Quota",
+ "advancedOptions": "Advanced",
+ "servingProxyShort": "Proxy",
+ "servingPublicShort": "Public",
+ "backendName": "Display name",
+ "badgeDefault": "Default",
+ "badgeDisabled": "Disabled",
+ "badgeEnabled": "Enabled",
+ "statTotal": "Total",
+ "statUsed": "Used",
+ "statFree": "Free",
+ "usageRate": "Usage",
+ "indexImages": "PicHost images {count} · {size}",
+ "setDefault": "Set as default",
+ "actionSetDefault": "Default",
+ "actionTest": "Test",
+ "disable": "Disable",
+ "enable": "Enable",
+ "usageCount": "{count} images",
+ "quotaTotal": "Plan size",
+ "quotaPlaceholder": "e.g. 10",
+ "quotaHint": "Enter your cloud plan size; used space is calculated from PicHost image index",
+ "quotaRequired": "Enter a valid plan size",
+ "notConfigured": "Not configured",
+ "created": "Storage backend added",
+ "deleted": "Storage backend deleted",
+ "deleteFailed": "Delete failed",
+ "deleteBlockedImages": "This backend still has images. Move or delete them before removing the backend.",
+ "defaultUpdated": "Default backend updated",
+ "deleteConfirmTitle": "Delete storage",
+ "deleteConfirmBody": "Delete “{name}”? This cannot be undone.",
+ "secretsRequired": "Access Key and Secret Key are required for new storage",
+ "manageStorage": "Manage object storage",
+ "manageStorageHint": "Configure R2 / S3 / COS backends and URL serving"
+ },
"stats": {
"title": "Statistics",
"subtitle": "Overview and gallery",
diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json
index e42dbd0..5214a0c 100644
--- a/i18n/locales/zh-CN.json
+++ b/i18n/locales/zh-CN.json
@@ -10,6 +10,7 @@
"cancel": "取消",
"copy": "复制",
"delete": "删除",
+ "edit": "编辑",
"search": "搜索",
"refresh": "刷新",
"back": "返回",
@@ -17,12 +18,14 @@
"notConfigured": "(未配置)",
"countImages": "{count} 张",
"itemsUnit": "条",
- "imagesUnit": "张"
+ "imagesUnit": "张",
+ "more": "更多"
},
"nav": {
"api": "API",
"stats": "统计",
"settings": "设置",
+ "storage": "存储",
"menu": "菜单",
"language": "语言",
"appearance": "外观",
@@ -191,6 +194,86 @@
"saveFailed": "保存服务器配置失败",
"loadFailed": "加载设置失败"
},
+ "storage": {
+ "title": "存储设置",
+ "subtitle": "新上传写入活动后端;已有图片仍按原后端读取",
+ "activeBackend": "活动后端",
+ "backendLocal": "本地磁盘",
+ "backendCloud": "云存储",
+ "backendS3": "云存储",
+ "switchHint": "切换仅影响新上传,已有图片不受影响",
+ "provider": "预设",
+ "providerR2": "Cloudflare R2",
+ "providerCos": "腾讯云 COS",
+ "providerOss": "阿里云 OSS",
+ "providerAws": "AWS S3",
+ "providerCustom": "自定义",
+ "endpoint": "Endpoint",
+ "region": "Region",
+ "bucket": "Bucket",
+ "prefix": "对象前缀(可选)",
+ "prefixPlaceholder": "例如 pichost",
+ "forcePathStyle": "强制 Path-Style 访问",
+ "accessKey": "Access Key",
+ "secretKey": "Secret Key",
+ "servingMode": "访问方式",
+ "servingProxy": "代理(PicHost 同源)",
+ "servingPublic": "公开 URL(302 到 CDN)",
+ "servingHint": "公开模式下 Referer 防盗链需在 CDN/桶策略侧配置",
+ "publicUrl": "公开访问 URL",
+ "publicUrlPlaceholder": "https://cdn.example.com",
+ "testConnection": "测试连接",
+ "testSuccess": "连接成功",
+ "testFailed": "连接失败",
+ "saved": "存储配置已保存",
+ "saveFailed": "保存存储配置失败",
+ "loadFailed": "加载存储配置失败",
+ "envOverride": "存储配置已由环境变量覆盖,无法在后台修改",
+ "pageTitle": "存储管理",
+ "pageSubtitle": "管理多个存储后端,新上传写入默认后端",
+ "addBackend": "添加存储",
+ "editBackend": "编辑存储",
+ "formCreateHint": "配置云存储连接,保存后可设为默认写入目标",
+ "formEditHint": "修改连接信息;留空密钥则保持原值",
+ "sectionBasic": "基本信息",
+ "sectionConnection": "连接配置",
+ "sectionCredentials": "访问凭证",
+ "sectionAccess": "访问方式",
+ "sectionQuota": "容量配额",
+ "advancedOptions": "高级选项",
+ "servingProxyShort": "代理",
+ "servingPublicShort": "公开",
+ "backendName": "显示名称",
+ "badgeDefault": "默认",
+ "badgeDisabled": "已停用",
+ "badgeEnabled": "已启用",
+ "statTotal": "总量",
+ "statUsed": "已用",
+ "statFree": "剩余",
+ "usageRate": "使用率",
+ "indexImages": "PicHost 图片 {count} 张 · {size}",
+ "setDefault": "设为默认",
+ "actionSetDefault": "设默认",
+ "actionTest": "测试",
+ "disable": "停用",
+ "enable": "启用",
+ "usageCount": "{count} 张图片",
+ "quotaTotal": "套餐总量",
+ "quotaPlaceholder": "例如 10",
+ "quotaHint": "填写云存储套餐总量;已用按 PicHost 图片索引自动统计",
+ "quotaRequired": "请填写有效的套餐总量",
+ "notConfigured": "未配置",
+ "created": "存储后端已添加",
+ "deleted": "存储后端已删除",
+ "deleteFailed": "删除失败",
+ "deleteBlockedImages": "该后端仍有图片,请先迁移或删除图片后再删除",
+ "defaultUpdated": "默认后端已更新",
+ "deleteConfirmTitle": "删除存储",
+ "deleteConfirmBody": "确定删除「{name}」?此操作不可撤销。",
+ "secretsRequired": "新建存储需填写 Access Key 与 Secret Key",
+ "manageStorage": "管理对象存储",
+ "manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略"
+ },
"stats": {
"title": "统计",
"subtitle": "数据概览与图库记录",
diff --git a/package-lock.json b/package-lock.json
index 93e71e4..2fcc5cf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,16 @@
{
"name": "pichost",
- "version": "1.0.4",
+ "version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pichost",
- "version": "1.0.4",
+ "version": "1.1.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+ "@aws-sdk/client-s3": "^3.876.0",
"@iconify-json/lucide": "^1.2.114",
"@nuxt/ui": "^4.9.0",
"@nuxtjs/i18n": "^10.6.0",
@@ -70,6 +71,314 @@
"@types/json-schema": "^7.0.15"
}
},
+ "node_modules/@aws-sdk/checksums": {
+ "version": "3.1000.29",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/checksums/-/checksums-3.1000.29.tgz",
+ "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3": {
+ "version": "3.1118.0",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/client-s3/-/client-s3-3.1118.0.tgz",
+ "integrity": "sha512-nh46pLKeRNFvTOZAJMfYR7kZ/KzhSqzhXVIJSwl4jJ/b2D+KOXL2jeOCryOvGgwpUQo9YNWFUg63cm2qr5MX9g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/checksums": "^3.1000.29",
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/credential-provider-node": "^3.972.81",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.75",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.46",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/fetch-http-handler": "^5.7.2",
+ "@smithy/node-http-handler": "^4.11.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.977.9",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/core/-/core-3.977.9.tgz",
+ "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.974.5",
+ "@aws-sdk/xml-builder": "^3.972.40",
+ "@aws/lambda-invoke-store": "^0.3.0",
+ "@smithy/core": "^3.33.3",
+ "@smithy/signature-v4": "^5.6.12",
+ "@smithy/types": "^4.17.2",
+ "bowser": "^2.11.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-env": {
+ "version": "3.972.70",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz",
+ "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-http": {
+ "version": "3.972.72",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz",
+ "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/fetch-http-handler": "^5.7.2",
+ "@smithy/node-http-handler": "^4.11.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-ini": {
+ "version": "3.973.15",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz",
+ "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/credential-provider-env": "^3.972.70",
+ "@aws-sdk/credential-provider-http": "^3.972.72",
+ "@aws-sdk/credential-provider-login": "^3.972.77",
+ "@aws-sdk/credential-provider-process": "^3.972.70",
+ "@aws-sdk/credential-provider-sso": "^3.973.14",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.76",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/credential-provider-imds": "^4.4.16",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-login": {
+ "version": "3.972.77",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz",
+ "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-node": {
+ "version": "3.972.81",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz",
+ "integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-provider-env": "^3.972.70",
+ "@aws-sdk/credential-provider-http": "^3.972.72",
+ "@aws-sdk/credential-provider-ini": "^3.973.15",
+ "@aws-sdk/credential-provider-process": "^3.972.70",
+ "@aws-sdk/credential-provider-sso": "^3.973.14",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.76",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/credential-provider-imds": "^4.4.16",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-process": {
+ "version": "3.972.70",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz",
+ "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-sso": {
+ "version": "3.973.14",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz",
+ "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/token-providers": "3.1116.0",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-web-identity": {
+ "version": "3.972.76",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz",
+ "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.75",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz",
+ "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.46",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.997.44",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz",
+ "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.46",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/fetch-http-handler": "^5.7.2",
+ "@smithy/node-http-handler": "^4.11.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.996.46",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz",
+ "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/signature-v4": "^5.6.12",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/token-providers": {
+ "version": "3.1116.0",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz",
+ "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/types": {
+ "version": "3.974.5",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/types/-/types-3.974.5.tgz",
+ "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.40",
+ "resolved": "https://registry.npmmirror.com/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz",
+ "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmmirror.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
+ "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -6508,6 +6817,87 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@smithy/core": {
+ "version": "3.33.3",
+ "resolved": "https://registry.npmmirror.com/@smithy/core/-/core-3.33.3.tgz",
+ "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/credential-provider-imds": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmmirror.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
+ "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/fetch-http-handler": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmmirror.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz",
+ "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-http-handler": {
+ "version": "4.11.3",
+ "resolved": "https://registry.npmmirror.com/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz",
+ "integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/signature-v4": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmmirror.com/@smithy/signature-v4/-/signature-v4-5.7.3.tgz",
+ "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/types": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmmirror.com/@smithy/types/-/types-4.17.2.tgz",
+ "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@speed-highlight/core": {
"version": "1.2.17",
"resolved": "https://registry.npmmirror.com/@speed-highlight/core/-/core-1.2.17.tgz",
@@ -9263,6 +9653,12 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
+ "node_modules/bowser": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmmirror.com/bowser/-/bowser-2.14.1.tgz",
+ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.7.tgz",
diff --git a/package.json b/package.json
index dc5b45a..e2f2ab3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "pichost",
- "version": "1.0.4",
- "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
+ "version": "1.1.0",
+ "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme",
"repository": {
"type": "git",
@@ -36,6 +36,7 @@
"reset-password": "node server/cli/reset-password.mjs"
},
"dependencies": {
+ "@aws-sdk/client-s3": "^3.876.0",
"@iconify-json/lucide": "^1.2.114",
"@nuxt/ui": "^4.9.0",
"@nuxtjs/i18n": "^10.6.0",
diff --git a/scripts/assign-image-owner.ps1 b/scripts/assign-image-owner.ps1
new file mode 100644
index 0000000..0c3b750
--- /dev/null
+++ b/scripts/assign-image-owner.ps1
@@ -0,0 +1,146 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+ Assign userId to image .meta.json files that are missing it (shows as 系统/API).
+
+.PARAMETER DataDir
+ PicHost data directory (contains images/, twikoo/, pichost.db). Default: ./data
+
+.PARAMETER UserId
+ Target user id. If omitted, uses the first admin from pichost.db.
+
+.PARAMETER Apply
+ Actually write files. Without this flag, only prints what would change (dry-run).
+
+.EXAMPLE
+ cd D:\Ou\PicHost
+ .\scripts\assign-image-owner.ps1
+
+.EXAMPLE
+ .\scripts\assign-image-owner.ps1 -DataDir D:\pic-host\data -Apply
+
+.EXAMPLE
+ .\scripts\assign-image-owner.ps1 -UserId 1 -Apply
+#>
+[CmdletBinding()]
+param(
+ [string] $DataDir = (Join-Path (Get-Location) 'data'),
+ [int] $UserId = 0,
+ [switch] $Apply
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Resolve-AdminUserId {
+ param([string] $DbPath)
+
+ if (-not (Test-Path -LiteralPath $DbPath)) {
+ Write-Warning "pichost.db not found at $DbPath; using UserId = 1"
+ return 1
+ }
+
+ $repoRoot = Split-Path -Parent $PSScriptRoot
+ $nodeFile = Join-Path $env:TEMP "pichost-read-admin-$PID.mjs"
+ @'
+import { DatabaseSync } from "node:sqlite";
+const db = new DatabaseSync(process.argv[2]);
+const row = db.prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1").get();
+if (!row) process.exit(2);
+process.stdout.write(String(row.id));
+db.close();
+'@ | Set-Content -LiteralPath $nodeFile -Encoding utf8
+
+ Push-Location $repoRoot
+ try {
+ $output = & node $nodeFile $DbPath 2>&1
+ if ($LASTEXITCODE -eq 2) {
+ throw "No admin user in database. Run /setup first or pass -UserId explicitly."
+ }
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to read admin from database: $output"
+ }
+ return [int]$output
+ }
+ finally {
+ Pop-Location
+ Remove-Item -LiteralPath $nodeFile -ErrorAction SilentlyContinue
+ }
+}
+
+function Read-MetaJson {
+ param([string] $Path)
+ $raw = [System.IO.File]::ReadAllText($Path, [System.Text.UTF8Encoding]::new($false))
+ return $raw | ConvertFrom-Json
+}
+
+function Write-MetaJson {
+ param(
+ [string] $Path,
+ [object] $Meta
+ )
+ # Compact single-line JSON, same style as PicHost putImage()
+ $json = $Meta | ConvertTo-Json -Compress -Depth 10
+ $utf8 = [System.Text.UTF8Encoding]::new($false)
+ [System.IO.File]::WriteAllText($Path, $json, $utf8)
+}
+
+if (-not (Test-Path -LiteralPath $DataDir)) {
+ throw "DataDir not found: $DataDir"
+}
+
+$resolved = Resolve-Path -LiteralPath $DataDir
+$DataDir = if ($resolved.ProviderPath) { $resolved.ProviderPath } else { $resolved.Path }
+
+if ($UserId -le 0) {
+ $UserId = Resolve-AdminUserId -DbPath (Join-Path $DataDir 'pichost.db')
+}
+
+Write-Host "DataDir : $DataDir"
+Write-Host "UserId : $UserId"
+Write-Host "Mode : $(if ($Apply) { 'APPLY (will modify files)' } else { 'DRY-RUN (pass -Apply to write)' })"
+Write-Host ''
+
+$metaFiles = Get-ChildItem -LiteralPath $DataDir -Recurse -File -Filter '*.meta.json' |
+ Where-Object { $_.Name -notlike 'pichost.db*' }
+
+if (-not $metaFiles) {
+ Write-Host 'No .meta.json files found under DataDir.'
+ exit 0
+}
+
+$patched = 0
+$skipped = 0
+$errors = 0
+
+foreach ($file in $metaFiles) {
+ try {
+ $meta = Read-MetaJson -Path $file.FullName
+ $current = $meta.PSObject.Properties['userId']
+ $hasUserId = $null -ne $current -and $null -ne $current.Value
+
+ if ($hasUserId) {
+ $skipped++
+ continue
+ }
+
+ $relative = [System.IO.Path]::GetRelativePath($DataDir, $file.FullName)
+ Write-Host "[patch] $relative -> userId $UserId"
+
+ if ($Apply) {
+ $meta | Add-Member -NotePropertyName userId -NotePropertyValue $UserId -Force
+ Write-MetaJson -Path $file.FullName -Meta $meta
+ }
+
+ $patched++
+ }
+ catch {
+ $errors++
+ Write-Warning "Failed: $($file.FullName) — $_"
+ }
+}
+
+Write-Host ''
+Write-Host "Done. patched=$patched skipped(already had userId)=$skipped errors=$errors"
+if (-not $Apply -and $patched -gt 0) {
+ Write-Host 'Re-run with -Apply to write changes.'
+}
diff --git a/server/api/admin/storage/[id].delete.ts b/server/api/admin/storage/[id].delete.ts
new file mode 100644
index 0000000..5d58044
--- /dev/null
+++ b/server/api/admin/storage/[id].delete.ts
@@ -0,0 +1,39 @@
+import { requireAdminAuth } from '../../../utils/access'
+import { createApiError } from '../../../utils/api-error'
+import {
+ deleteStorageBackend,
+ isStorageEnvConfigured,
+ LOCAL_BACKEND_ID
+} from '../../../utils/storage-backends'
+
+export default defineEventHandler(async (event) => {
+ await requireAdminAuth(event)
+
+ if (isStorageEnvConfigured()) {
+ createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
+ }
+
+ const id = getRouterParam(event, 'id')
+ if (!id) {
+ createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
+ }
+
+ if (id === LOCAL_BACKEND_ID) {
+ createApiError(event, 'INVALID_REQUEST', '本地磁盘后端不可删除', 400)
+ }
+
+ try {
+ deleteStorageBackend(id)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '删除失败'
+ if (message.includes('stored images')) {
+ createApiError(event, 'INVALID_REQUEST', '该后端仍有图片,无法删除', 400)
+ }
+ if (message.includes('default backend')) {
+ createApiError(event, 'INVALID_REQUEST', '请先切换默认后端后再删除', 400)
+ }
+ createApiError(event, 'INVALID_REQUEST', message, 400)
+ }
+
+ return { success: true }
+})
diff --git a/server/api/admin/storage/[id].patch.ts b/server/api/admin/storage/[id].patch.ts
new file mode 100644
index 0000000..a4375ed
--- /dev/null
+++ b/server/api/admin/storage/[id].patch.ts
@@ -0,0 +1,129 @@
+import { requireAdminAuth } from '../../../utils/access'
+import { createApiError } from '../../../utils/api-error'
+import {
+ isStorageEnvConfigured,
+ LOCAL_BACKEND_ID,
+ updateStorageBackend,
+ getStorageBackendRow,
+ setDefaultBackend,
+ type StorageBackendPatch
+} from '../../../utils/storage-backends'
+import { parseS3Config, validateS3Config } from '../../../utils/storage/s3'
+import { parseQuotaBytes } from '../../../utils/storage-capacity'
+import type { ServingMode } from '../../../utils/storage/types'
+
+interface StoragePatchBody {
+ name?: string
+ config?: {
+ endpoint?: string
+ region?: string
+ bucket?: string
+ prefix?: string
+ forcePathStyle?: boolean
+ }
+ secrets?: {
+ accessKeyId?: string
+ secretAccessKey?: string
+ }
+ servingMode?: ServingMode
+ publicUrl?: string
+ quotaBytes?: number | null
+ enabled?: boolean
+ isDefault?: boolean
+}
+
+export default defineEventHandler(async (event) => {
+ await requireAdminAuth(event)
+
+ if (isStorageEnvConfigured()) {
+ createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
+ }
+
+ const id = getRouterParam(event, 'id')
+ if (!id) {
+ createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
+ }
+
+ const row = getStorageBackendRow(id)
+ if (!row) {
+ createApiError(event, 'INVALID_REQUEST', '存储后端不存在', 404)
+ }
+
+ const body = await readBody(event).catch(
+ (): StoragePatchBody => ({})
+ )
+
+ if (id === LOCAL_BACKEND_ID && body.isDefault === false) {
+ createApiError(event, 'INVALID_REQUEST', '必须保留一个默认存储后端', 400)
+ }
+
+ const patch: StorageBackendPatch = {}
+
+ if (body.name !== undefined) {
+ patch.name = body.name.trim() || row.name
+ }
+
+ if (body.config !== undefined && row.type === 's3') {
+ const current = parseS3Config(row.config_json) ?? {
+ endpoint: '',
+ region: 'auto',
+ bucket: ''
+ }
+ const next = {
+ endpoint: body.config.endpoint?.trim() ?? current.endpoint,
+ region: body.config.region?.trim() ?? current.region,
+ bucket: body.config.bucket?.trim() ?? current.bucket,
+ prefix: body.config.prefix?.trim() || undefined,
+ forcePathStyle: body.config.forcePathStyle ?? current.forcePathStyle
+ }
+ const error = validateS3Config(next)
+ if (error) {
+ createApiError(event, 'INVALID_REQUEST', error, 400)
+ }
+ patch.config = next
+ }
+
+ if (body.secrets !== undefined && row.type === 's3') {
+ patch.secrets = body.secrets
+ }
+
+ if (body.servingMode !== undefined) {
+ if (body.servingMode !== 'proxy' && body.servingMode !== 'public') {
+ createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400)
+ }
+ patch.servingMode = body.servingMode
+ }
+
+ if (body.publicUrl !== undefined) {
+ patch.publicUrl = body.publicUrl.trim()
+ }
+
+ if (body.quotaBytes !== undefined) {
+ if (row.type === 's3') {
+ const quotaBytes = parseQuotaBytes(body.quotaBytes)
+ if (!quotaBytes) {
+ createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400)
+ }
+ patch.quotaBytes = quotaBytes
+ }
+ }
+
+ if (body.enabled !== undefined) {
+ if (id === LOCAL_BACKEND_ID && !body.enabled) {
+ createApiError(event, 'INVALID_REQUEST', '本地磁盘后端不可停用', 400)
+ }
+ patch.enabled = body.enabled
+ }
+
+ if (body.isDefault !== undefined) {
+ patch.isDefault = body.isDefault
+ }
+
+ updateStorageBackend(id, patch)
+
+ if (body.isDefault) {
+ setDefaultBackend(id)
+ }
+
+ return { success: true }
+})
diff --git a/server/api/admin/storage/[id]/test.post.ts b/server/api/admin/storage/[id]/test.post.ts
new file mode 100644
index 0000000..077d75e
--- /dev/null
+++ b/server/api/admin/storage/[id]/test.post.ts
@@ -0,0 +1,34 @@
+import { requireAdminAuth } from '../../../../utils/access'
+import { createApiError } from '../../../../utils/api-error'
+import {
+ createBackendInstance,
+ getStorageBackendRow,
+ isStorageEnvConfigured
+} from '../../../../utils/storage-backends'
+
+export default defineEventHandler(async (event) => {
+ await requireAdminAuth(event)
+
+ if (isStorageEnvConfigured()) {
+ createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台测试', 409)
+ }
+
+ const id = getRouterParam(event, 'id')
+ if (!id) {
+ createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
+ }
+
+ const row = getStorageBackendRow(id)
+ if (!row) {
+ createApiError(event, 'INVALID_REQUEST', '存储后端不存在', 404)
+ }
+
+ try {
+ const backend = createBackendInstance(row)
+ const result = await backend.testConnection()
+ return result
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '连接失败'
+ return { ok: false, message }
+ }
+})
diff --git a/server/api/admin/storage/index.get.ts b/server/api/admin/storage/index.get.ts
new file mode 100644
index 0000000..f38bc08
--- /dev/null
+++ b/server/api/admin/storage/index.get.ts
@@ -0,0 +1,39 @@
+import { requireAdminAuth } from '../../../utils/access'
+import {
+ getBackendUsageStats,
+ isStorageEnvConfigured,
+ listStorageBackends
+} from '../../../utils/storage-backends'
+import { buildBackendCapacity } from '../../../utils/storage-capacity'
+import { getActiveBackendRow } from '../../../utils/storage/resolver'
+import type { StorageBackendInfo } from '../../../utils/storage/types'
+
+export default defineEventHandler(async (event) => {
+ await requireAdminAuth(event)
+
+ const rows = listStorageBackends()
+ const backends = await Promise.all(rows.map(async (backend: StorageBackendInfo & {
+ secretsMasked: Record
+ }) => {
+ const usage = getBackendUsageStats(backend.id)
+ const capacity = await buildBackendCapacity(
+ backend.id,
+ backend.type,
+ usage.bytes,
+ backend.quotaBytes
+ )
+ return {
+ ...backend,
+ usage,
+ capacity
+ }
+ }))
+
+ const activeBackend = getActiveBackendRow()
+
+ return {
+ backends,
+ activeBackendId: activeBackend?.id ?? 'local',
+ envOverride: isStorageEnvConfigured()
+ }
+})
diff --git a/server/api/admin/storage/index.post.ts b/server/api/admin/storage/index.post.ts
new file mode 100644
index 0000000..f5c9bca
--- /dev/null
+++ b/server/api/admin/storage/index.post.ts
@@ -0,0 +1,83 @@
+import { requireAdminAuth } from '../../../utils/access'
+import { createApiError } from '../../../utils/api-error'
+import {
+ insertStorageBackend,
+ isStorageEnvConfigured
+} from '../../../utils/storage-backends'
+import { validateS3Config } from '../../../utils/storage/s3'
+import { parseQuotaBytes } from '../../../utils/storage-capacity'
+import type { ServingMode } from '../../../utils/storage/types'
+
+interface StorageCreateBody {
+ name?: string
+ config?: {
+ endpoint?: string
+ region?: string
+ bucket?: string
+ prefix?: string
+ forcePathStyle?: boolean
+ }
+ secrets?: {
+ accessKeyId?: string
+ secretAccessKey?: string
+ }
+ servingMode?: ServingMode
+ publicUrl?: string
+ quotaBytes?: number | null
+ enabled?: boolean
+ isDefault?: boolean
+}
+
+export default defineEventHandler(async (event) => {
+ await requireAdminAuth(event)
+
+ if (isStorageEnvConfigured()) {
+ createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
+ }
+
+ const body = await readBody(event).catch(
+ (): StorageCreateBody => ({})
+ )
+
+ const name = body.name?.trim() || 'S3 兼容存储'
+ const config = {
+ endpoint: body.config?.endpoint?.trim() ?? '',
+ region: body.config?.region?.trim() || 'auto',
+ bucket: body.config?.bucket?.trim() ?? '',
+ prefix: body.config?.prefix?.trim() || undefined,
+ forcePathStyle: body.config?.forcePathStyle ?? false
+ }
+
+ const error = validateS3Config(config)
+ if (error) {
+ createApiError(event, 'INVALID_REQUEST', error, 400)
+ }
+
+ const accessKeyId = body.secrets?.accessKeyId?.trim() ?? ''
+ const secretAccessKey = body.secrets?.secretAccessKey?.trim() ?? ''
+ if (!accessKeyId || !secretAccessKey) {
+ createApiError(event, 'INVALID_REQUEST', 'Access Key 与 Secret Key 不能为空', 400)
+ }
+
+ if (body.servingMode && body.servingMode !== 'proxy' && body.servingMode !== 'public') {
+ createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400)
+ }
+
+ const quotaBytes = parseQuotaBytes(body.quotaBytes)
+ if (!quotaBytes) {
+ createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400)
+ }
+
+ const id = insertStorageBackend({
+ name,
+ config,
+ secrets: { accessKeyId, secretAccessKey },
+ servingMode: body.servingMode,
+ publicUrl: body.publicUrl?.trim(),
+ quotaBytes,
+ enabled: body.enabled,
+ isDefault: body.isDefault
+ })
+
+ return { success: true, id }
+})
diff --git a/server/plugins/startup.ts b/server/plugins/startup.ts
index 49f2566..ff7d696 100644
--- a/server/plugins/startup.ts
+++ b/server/plugins/startup.ts
@@ -1,5 +1,7 @@
-import { getDataDir } from '../utils/storage'
+import { getDataDir } from '../utils/data-dir'
import { runAutoDeleteCleanup } from '../utils/auto-delete'
+import { migrateLocalImagesToIndex } from '../utils/image-index'
+import { ensureDefaultBackends } from '../utils/storage-backends'
import { logInfo } from '../utils/logger'
export default defineNitroPlugin(() => {
@@ -9,9 +11,17 @@ export default defineNitroPlugin(() => {
dataDir: getDataDir(),
nodeEnv: process.env.NODE_ENV || 'development',
imageBaseUrl: process.env.IMAGE_BASE_URL || '(request origin)',
+ storageBackend: process.env.STORAGE_BACKEND || 'db',
logLevel: process.env.LOG_LEVEL || 'info'
})
+ ensureDefaultBackends()
+ void migrateLocalImagesToIndex().then((count) => {
+ if (count > 0) {
+ logInfo('migrated local images to index', { count })
+ }
+ })
+
void runAutoDeleteCleanup().then((result) => {
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
logInfo('startup auto-delete', { ...result })
diff --git a/server/utils/auto-delete.ts b/server/utils/auto-delete.ts
index 6721a44..d31d01b 100644
--- a/server/utils/auto-delete.ts
+++ b/server/utils/auto-delete.ts
@@ -1,6 +1,7 @@
import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type AutoDeletePolicy } from './db'
import { getGlobalAutoDeletePolicy } from './env'
-import { deleteImage, headImage, listImageKeys, type StoredImage } from './storage'
+import { deleteImage, type StoredImage } from './storage'
+import { listImageKeysForAutoDelete } from './image-index'
import { logInfo, logWarn } from './logger'
export interface AutoDeleteResult {
@@ -73,23 +74,20 @@ export async function runAutoDeleteCleanup(): Promise {
}
const nowMs = Date.now()
- const keys = await listImageKeys()
+ const images = await listImageKeysForAutoDelete()
let deleted = 0
let failed = 0
- for (const key of keys) {
- const image = await headImage(key)
- if (!image) continue
-
+ for (const image of images) {
if (!shouldAutoDeleteImage(image, globalPolicy, adminUserIds, userPolicies, nowMs)) {
continue
}
try {
- await deleteImage(key)
+ await deleteImage(image.key)
insertActivityLog({
action: 'delete',
- key,
+ key: image.key,
originalName: image.originalName,
size: image.size,
contentType: image.contentType,
@@ -97,7 +95,7 @@ export async function runAutoDeleteCleanup(): Promise {
})
deleted++
} catch (error) {
- logWarn('auto-delete failed', { key, error })
+ logWarn('auto-delete failed', { key: image.key, error })
failed++
}
}
diff --git a/server/utils/data-dir.ts b/server/utils/data-dir.ts
new file mode 100644
index 0000000..07916cd
--- /dev/null
+++ b/server/utils/data-dir.ts
@@ -0,0 +1,5 @@
+import { join } from 'node:path'
+
+export function getDataDir(): string {
+ return process.env.DATA_DIR || join(process.cwd(), 'data')
+}
diff --git a/server/utils/db.ts b/server/utils/db.ts
index 467f5a3..acb98ae 100644
--- a/server/utils/db.ts
+++ b/server/utils/db.ts
@@ -1,7 +1,7 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
-import { getDataDir } from './storage'
+import { getDataDir } from './data-dir'
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
@@ -29,7 +29,7 @@ export interface ActivityLogInput {
let db: DatabaseSync | null = null
-function getDb(): DatabaseSync {
+export function getDb(): DatabaseSync {
if (db) return db
const dataDir = getDataDir()
diff --git a/server/utils/image-index.test.ts b/server/utils/image-index.test.ts
new file mode 100644
index 0000000..b92a70a
--- /dev/null
+++ b/server/utils/image-index.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+import { validateImageKey } from './image-key'
+
+describe('image index key validation', () => {
+ it('accepts standard image keys used in index', () => {
+ expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
+ expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(true)
+ })
+
+ it('rejects path traversal keys', () => {
+ expect(validateImageKey('../secret')).toBe(false)
+ expect(validateImageKey('images/../../etc/passwd')).toBe(false)
+ })
+})
diff --git a/server/utils/image-index.ts b/server/utils/image-index.ts
new file mode 100644
index 0000000..de32a0a
--- /dev/null
+++ b/server/utils/image-index.ts
@@ -0,0 +1,418 @@
+import { promises as fs } from 'node:fs'
+import { join } from 'node:path'
+import { getDb } from './db'
+import { getDataDir } from './data-dir'
+import { isValidFolderName, validateImageKey, DEFAULT_FOLDER } from './image-key'
+import {
+ ensureDefaultBackends,
+ ensureStorageSchema,
+ LOCAL_BACKEND_ID
+} from './storage-backends'
+import type { PaginatedResult, StoredImage, StoredImageMeta } from './storage/types'
+
+const META_SUFFIX = '.meta.json'
+
+export interface ImageIndexRow {
+ key: string
+ backend_id: string
+ user_id: number | null
+ folder: string
+ original_name: string
+ content_type: string
+ size: number
+ uploaded_at: string
+}
+
+function keyToFilePath(key: string): string {
+ return join(getDataDir(), ...key.split('/'))
+}
+
+async function readLocalMeta(key: string): Promise> {
+ try {
+ const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
+ return JSON.parse(raw) as Partial
+ } catch {
+ return {}
+ }
+}
+
+function rowToStoredImage(row: ImageIndexRow): StoredImage {
+ const uploadedMs = new Date(row.uploaded_at).getTime()
+ return {
+ key: row.key,
+ backendId: row.backend_id,
+ originalName: row.original_name,
+ uploadedAt: row.uploaded_at,
+ contentType: row.content_type,
+ size: row.size,
+ userId: row.user_id,
+ mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
+ }
+}
+
+function buildUserFilterSql(
+ userFilter: number | 'admin' | undefined,
+ params: Array
+): string {
+ if (userFilter === undefined || userFilter === 'admin') return ''
+ params.push(userFilter)
+ return ' AND user_id = ?'
+}
+
+export function insertImageIndex(input: {
+ key: string
+ backendId: string
+ userId?: number | null
+ originalName: string
+ contentType: string
+ size: number
+ uploadedAt: string
+}): void {
+ ensureStorageSchema()
+ const folder = input.key.split('/')[0] ?? DEFAULT_FOLDER
+ getDb().prepare(`
+ INSERT INTO images
+ (key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(key) DO UPDATE SET
+ backend_id = excluded.backend_id,
+ user_id = excluded.user_id,
+ folder = excluded.folder,
+ original_name = excluded.original_name,
+ content_type = excluded.content_type,
+ size = excluded.size,
+ uploaded_at = excluded.uploaded_at
+ `).run(
+ input.key,
+ input.backendId,
+ input.userId ?? null,
+ folder,
+ input.originalName,
+ input.contentType,
+ input.size,
+ input.uploadedAt
+ )
+}
+
+export function deleteImageIndex(key: string): void {
+ ensureStorageSchema()
+ getDb().prepare('DELETE FROM images WHERE key = ?').run(key)
+}
+
+export function getImageIndexRow(key: string): ImageIndexRow | null {
+ ensureStorageSchema()
+ const row = getDb().prepare(`
+ SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
+ FROM images
+ WHERE key = ?
+ `).get(key) as ImageIndexRow | undefined
+ return row ?? null
+}
+
+export function getImageIndexAsStored(key: string): StoredImage | null {
+ const row = getImageIndexRow(key)
+ return row ? rowToStoredImage(row) : null
+}
+
+async function readDirNames(path: string): Promise {
+ try {
+ const entries = await fs.readdir(path, { withFileTypes: true })
+ return entries.filter(e => e.isDirectory()).map(e => e.name)
+ } catch {
+ return []
+ }
+}
+
+async function readFileNames(path: string): Promise {
+ try {
+ const entries = await fs.readdir(path, { withFileTypes: true })
+ return entries.filter(e => e.isFile()).map(e => e.name)
+ } catch {
+ return []
+ }
+}
+
+async function listLocalImageKeys(): Promise {
+ const keys: string[] = []
+ const dataDir = getDataDir()
+ const prefixes = await readDirNames(dataDir)
+
+ for (const prefix of prefixes) {
+ if (!isValidFolderName(prefix)) continue
+ const root = join(dataDir, prefix)
+ const years = await readDirNames(root)
+ for (const year of years) {
+ const months = await readDirNames(join(root, year))
+ for (const month of months) {
+ const files = await readFileNames(join(root, year, month))
+ for (const file of files) {
+ if (file.endsWith(META_SUFFIX)) continue
+ const key = `${prefix}/${year}/${month}/${file}`
+ if (validateImageKey(key)) keys.push(key)
+ }
+ }
+ }
+ }
+
+ return keys
+}
+
+export async function migrateLocalImagesToIndex(): Promise {
+ ensureDefaultBackends()
+ const keys = await listLocalImageKeys()
+ let inserted = 0
+
+ for (const key of keys) {
+ const existing = getImageIndexRow(key)
+ if (existing) continue
+
+ let stat
+ try {
+ stat = await fs.stat(keyToFilePath(key))
+ } catch {
+ continue
+ }
+
+ const meta = await readLocalMeta(key)
+ const uploadedAt = meta.uploadedAt ?? stat.mtime.toISOString()
+
+ insertImageIndex({
+ key,
+ backendId: LOCAL_BACKEND_ID,
+ userId: meta.userId ?? null,
+ originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
+ contentType: meta.contentType ?? 'application/octet-stream',
+ size: stat.size,
+ uploadedAt
+ })
+ inserted++
+ }
+
+ return inserted
+}
+
+export async function listFolders(): Promise {
+ ensureStorageSchema()
+ const rows = getDb().prepare(`
+ SELECT DISTINCT folder FROM images ORDER BY folder ASC
+ `).all() as Array<{ folder: string }>
+
+ const folders = rows.map(row => row.folder).filter(isValidFolderName)
+ if (!folders.includes(DEFAULT_FOLDER)) {
+ folders.push(DEFAULT_FOLDER)
+ }
+ return folders.sort((a, b) => a.localeCompare(b))
+}
+
+export async function listFoldersForUser(
+ userFilter?: number | 'admin'
+): Promise {
+ ensureStorageSchema()
+ const params: Array = []
+ const userSql = buildUserFilterSql(userFilter, params)
+
+ const rows = getDb().prepare(`
+ SELECT DISTINCT folder FROM images WHERE 1=1${userSql} ORDER BY folder ASC
+ `).all(...params) as Array<{ folder: string }>
+
+ const folders = rows.map(row => row.folder).filter(isValidFolderName)
+ if (!folders.length) {
+ folders.push(DEFAULT_FOLDER)
+ }
+ return folders.sort((a, b) => a.localeCompare(b))
+}
+
+export async function countImages(
+ folder?: string,
+ userFilter?: number | 'admin'
+): Promise {
+ ensureStorageSchema()
+ const params: Array = []
+ const clauses: string[] = ['1=1']
+
+ if (folder && isValidFolderName(folder)) {
+ clauses.push('folder = ?')
+ params.push(folder)
+ }
+
+ const userSql = buildUserFilterSql(userFilter, params)
+ const row = getDb().prepare(`
+ SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}
+ `).get(...params) as { count: number }
+
+ return row.count
+}
+
+function paginateSlice(
+ all: T[],
+ limit: number,
+ page?: number
+): PaginatedResult {
+ const total = all.length
+ const pageSize = limit
+ const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
+ const requestedPage = Math.max(1, page ?? 1)
+ const safePage = Math.min(requestedPage, totalPages)
+ const start = (safePage - 1) * pageSize
+ const items = all.slice(start, start + pageSize)
+
+ return { items, total, page: safePage, pageSize, totalPages }
+}
+
+export async function listImages(options: {
+ limit: number
+ page?: number
+ folder?: string
+ userFilter?: number | 'admin'
+}): Promise> {
+ ensureStorageSchema()
+ const params: Array = []
+ const clauses: string[] = ['1=1']
+
+ if (options.folder && isValidFolderName(options.folder)) {
+ clauses.push('folder = ?')
+ params.push(options.folder)
+ }
+
+ const userSql = buildUserFilterSql(options.userFilter, params)
+ const rows = getDb().prepare(`
+ SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
+ FROM images
+ WHERE ${clauses.join(' AND ')}${userSql}
+ ORDER BY uploaded_at DESC, key DESC
+ `).all(...params) as unknown as ImageIndexRow[]
+
+ const sliced = paginateSlice(rows, options.limit, options.page)
+ return {
+ ...sliced,
+ items: sliced.items.map(rowToStoredImage)
+ }
+}
+
+export async function searchImages(options: {
+ query: string
+ limit: number
+ page?: number
+ folder?: string
+ userFilter?: number | 'admin'
+}): Promise> {
+ ensureStorageSchema()
+ const needle = options.query.trim().toLowerCase()
+ const params: Array = []
+ const clauses: string[] = ['1=1']
+
+ if (options.folder && isValidFolderName(options.folder)) {
+ clauses.push('folder = ?')
+ params.push(options.folder)
+ }
+
+ const userSql = buildUserFilterSql(options.userFilter, params)
+ const rows = getDb().prepare(`
+ SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
+ FROM images
+ WHERE ${clauses.join(' AND ')}${userSql}
+ ORDER BY uploaded_at DESC, key DESC
+ `).all(...params) as unknown as ImageIndexRow[]
+
+ const matches = rows.filter((row) => {
+ if (!needle) return true
+ return row.key.toLowerCase().includes(needle)
+ || row.original_name.toLowerCase().includes(needle)
+ })
+
+ const sliced = paginateSlice(matches, options.limit, options.page)
+ return {
+ ...sliced,
+ items: sliced.items.map(rowToStoredImage)
+ }
+}
+
+export async function getFolderStorageStats(
+ userFilter?: number | 'admin'
+): Promise> {
+ ensureStorageSchema()
+ const params: Array = []
+ const userSql = buildUserFilterSql(userFilter, params)
+
+ const rows = getDb().prepare(`
+ SELECT folder, COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
+ FROM images
+ WHERE 1=1${userSql}
+ GROUP BY folder
+ ORDER BY bytes DESC, folder ASC
+ `).all(...params) as Array<{ folder: string, count: number, bytes: number }>
+
+ return rows
+}
+
+function startOfDayIsoInShanghai(now = new Date()): string {
+ const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
+ const y = shifted.getUTCFullYear()
+ const m = shifted.getUTCMonth()
+ const d = shifted.getUTCDate()
+ return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
+}
+
+function startOfMonthIsoInShanghai(now = new Date()): string {
+ const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
+ const y = shifted.getUTCFullYear()
+ const m = shifted.getUTCMonth()
+ return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
+}
+
+export async function getUserScopedStorageStats(userId: number): Promise<{
+ storedCount: number
+ uploadBytesTotal: number
+ uploadToday: number
+ uploadMonth: number
+ byFolder: Array<{ folder: string, count: number, bytes: number }>
+}> {
+ ensureStorageSchema()
+ const startOfDay = startOfDayIsoInShanghai()
+ const startOfMonth = startOfMonthIsoInShanghai()
+
+ const rows = getDb().prepare(`
+ SELECT folder, size, uploaded_at
+ FROM images
+ WHERE user_id = ?
+ `).all(userId) as Array<{ folder: string, size: number, uploaded_at: string }>
+
+ const map = new Map()
+ let uploadBytesTotal = 0
+ let uploadToday = 0
+ let uploadMonth = 0
+
+ for (const row of rows) {
+ uploadBytesTotal += row.size
+ if (row.uploaded_at >= startOfDay) uploadToday += 1
+ if (row.uploaded_at >= startOfMonth) uploadMonth += 1
+
+ const current = map.get(row.folder) ?? { count: 0, bytes: 0 }
+ current.count += 1
+ current.bytes += row.size
+ map.set(row.folder, current)
+ }
+
+ const byFolder = [...map.entries()]
+ .map(([folder, stat]) => ({ folder, ...stat }))
+ .sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
+
+ return {
+ storedCount: rows.length,
+ uploadBytesTotal,
+ uploadToday,
+ uploadMonth,
+ byFolder
+ }
+}
+
+export async function listImageKeysForAutoDelete(): Promise {
+ ensureStorageSchema()
+ const rows = getDb().prepare(`
+ SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
+ FROM images
+ ORDER BY uploaded_at ASC
+ `).all() as unknown as ImageIndexRow[]
+
+ return rows.map(rowToStoredImage)
+}
diff --git a/server/utils/image-response.ts b/server/utils/image-response.ts
index bec1474..3197d17 100644
--- a/server/utils/image-response.ts
+++ b/server/utils/image-response.ts
@@ -3,6 +3,10 @@ import type { ImageItem } from '~/types/image'
import type { AllowedMimeType } from './constants'
import type { StoredImage } from './storage'
import { getImageBaseUrl } from './env'
+import { getActiveBackendRow } from './storage/resolver'
+import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends'
+import { buildObjectKey, parseS3Config } from './storage/s3'
+import type { StorageBackendRow } from './storage/types'
export function resolveImageOwner(
userId: number | null | undefined,
@@ -27,7 +31,8 @@ export function mapStoredImageToItem(
): ImageItem {
const item = buildImageItem({
key: stored.key,
- baseUrl: getImageBaseUrl(event),
+ event,
+ backendId: stored.backendId,
originalName: stored.originalName,
contentType: stored.contentType,
size: stored.size,
@@ -47,6 +52,42 @@ export function buildImageUrl(baseUrl: string, key: string): string {
return `${normalizedBase}/${key}`
}
+export function buildPublicImageUrl(backendRow: StorageBackendRow, key: string): string {
+ const base = backendRow.public_url.replace(/\/$/, '')
+ if (backendRow.type === 's3') {
+ const config = parseS3Config(backendRow.config_json)
+ const objectKey = buildObjectKey(config?.prefix, key)
+ return `${base}/${objectKey}`
+ }
+ return `${base}/${key}`
+}
+
+export function resolveImageUrl(
+ event: H3Event,
+ key: string,
+ backendId?: string
+): string {
+ const envConfig = getStorageBackendFromEnv()
+ if (envConfig && (!backendId || backendId === 's3-primary')) {
+ if (envConfig.servingMode === 'public' && envConfig.publicUrl) {
+ const prefix = envConfig.config.prefix as string | undefined
+ const objectKey = buildObjectKey(prefix, key)
+ return `${envConfig.publicUrl.replace(/\/$/, '')}/${objectKey}`
+ }
+ return buildImageUrl(getImageBaseUrl(event), key)
+ }
+
+ const backendRow = backendId
+ ? getStorageBackendRow(backendId)
+ : getActiveBackendRow()
+
+ if (backendRow?.serving_mode === 'public' && backendRow.public_url) {
+ return buildPublicImageUrl(backendRow, key)
+ }
+
+ return buildImageUrl(getImageBaseUrl(event), key)
+}
+
export function buildMarkdown(url: string, alt: string): string {
const safeAlt = alt.replace(/[[\]]/g, '')
return ``
@@ -68,13 +109,17 @@ function escapeHtml(value: string): string {
export function buildImageItem(input: {
key: string
- baseUrl: string
+ baseUrl?: string
+ event?: H3Event
+ backendId?: string
originalName: string
contentType: AllowedMimeType | string
size: number
uploadedAt: string
}) {
- const url = buildImageUrl(input.baseUrl, input.key)
+ const url = input.event
+ ? resolveImageUrl(input.event, input.key, input.backendId)
+ : buildImageUrl(input.baseUrl ?? '', input.key)
const alt = sanitizeOriginalName(input.originalName)
return {
diff --git a/server/utils/process-image-upload.ts b/server/utils/process-image-upload.ts
index 003c822..6ce48d2 100644
--- a/server/utils/process-image-upload.ts
+++ b/server/utils/process-image-upload.ts
@@ -14,7 +14,7 @@ import {
import type { LogSource } from './db'
import { generateImageKey } from './image-key'
import { buildImageItem, sanitizeOriginalName } from './image-response'
-import { getImageBaseUrl, getWebpQuality } from './env'
+import { getWebpQuality } from './env'
import { insertActivityLog } from './db'
import { putImage } from './storage'
import { logInfo, logWarn } from './logger'
@@ -121,7 +121,6 @@ export async function processSingleImageUpload(
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event))
- const imageBaseUrl = getImageBaseUrl(event)
const uploadedAt = new Date().toISOString()
const key = generateImageKey(
compressed.mime,
@@ -173,7 +172,7 @@ export async function processSingleImageUpload(
return {
item: buildImageItem({
key,
- baseUrl: imageBaseUrl,
+ event,
originalName,
contentType: compressed.mime,
size: compressed.bytes.byteLength,
diff --git a/server/utils/serve-image.ts b/server/utils/serve-image.ts
index d0e7e13..4c3f041 100644
--- a/server/utils/serve-image.ts
+++ b/server/utils/serve-image.ts
@@ -2,7 +2,9 @@ import type { H3Event } from 'h3'
import { IMAGE_CACHE_CONTROL } from './constants'
import { getAllowedRefererHostsRaw } from './env'
import { validateImageKey } from './image-key'
+import { buildPublicImageUrl } from './image-response'
import { createImageStream, headImage } from './storage'
+import { getBackendRowForKey } from './storage/resolver'
/**
* 图片直链处理(/{type}/YYYY/MM/file)
@@ -82,6 +84,12 @@ export async function serveImageByKey(event: H3Event, key: string) {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
+ const backendRow = await getBackendRowForKey(key)
+ if (backendRow.serving_mode === 'public' && backendRow.public_url) {
+ const publicUrl = buildPublicImageUrl(backendRow, key)
+ return sendRedirect(event, publicUrl, 302)
+ }
+
const etag = `"${stored.size}-${Math.round(stored.mtimeMs)}"`
setHeader(event, 'ETag', etag)
@@ -116,9 +124,9 @@ export async function serveImageByKey(event: H3Event, key: string) {
setResponseStatus(event, 206)
setHeader(event, 'Content-Range', `bytes ${range.start}-${range.end}/${stored.size}`)
setHeader(event, 'Content-Length', range.end - range.start + 1)
- return sendStream(event, createImageStream(key, range))
+ return sendStream(event, await createImageStream(key, range))
}
setHeader(event, 'Content-Length', stored.size)
- return sendStream(event, createImageStream(key))
+ return sendStream(event, await createImageStream(key))
}
diff --git a/server/utils/storage-backends.ts b/server/utils/storage-backends.ts
new file mode 100644
index 0000000..d6636d8
--- /dev/null
+++ b/server/utils/storage-backends.ts
@@ -0,0 +1,406 @@
+import type { DatabaseSync } from 'node:sqlite'
+import { getDb } from './db'
+import { LocalStorageBackend } from './storage/local'
+import {
+ parseS3Config,
+ parseS3Secrets,
+ S3StorageBackend
+} from './storage/s3'
+import type {
+ S3BackendConfig,
+ ServingMode,
+ StorageBackendInfo,
+ StorageBackendRow,
+ StorageBackendType
+} from './storage/types'
+
+export const LOCAL_BACKEND_ID = 'local'
+export const S3_BACKEND_ID = 's3-primary'
+
+function migrateStorageTables(database: DatabaseSync): void {
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS storage_backends (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL CHECK(type IN ('local', 's3')),
+ 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
+ );
+ CREATE TABLE IF NOT EXISTS images (
+ key TEXT PRIMARY KEY,
+ backend_id TEXT NOT NULL,
+ user_id INTEGER,
+ folder TEXT NOT NULL,
+ original_name TEXT NOT NULL,
+ content_type TEXT NOT NULL,
+ size INTEGER NOT NULL,
+ uploaded_at TEXT NOT NULL,
+ FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_images_backend_id ON images(backend_id);
+ CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);
+ CREATE INDEX IF NOT EXISTS idx_images_folder ON images(folder);
+ `)
+}
+
+export function ensureStorageSchema(): void {
+ migrateStorageTables(getDb())
+}
+
+export function ensureDefaultBackends(): void {
+ ensureStorageSchema()
+ const db = getDb()
+
+ const local = db.prepare(`
+ SELECT id FROM storage_backends WHERE id = ?
+ `).get(LOCAL_BACKEND_ID)
+
+ if (!local) {
+ db.prepare(`
+ INSERT INTO storage_backends
+ (id, name, type, config_json, secret_json, serving_mode, public_url,
+ quota_bytes, enabled, is_default, sort_order)
+ VALUES (?, ?, 'local', '{}', '{}', 'proxy', '', NULL, 1, 1, 0)
+ `).run(LOCAL_BACKEND_ID, '本地磁盘')
+ }
+
+ const s3 = db.prepare(`
+ SELECT id FROM storage_backends WHERE id = ?
+ `).get(S3_BACKEND_ID)
+
+ if (!s3) {
+ db.prepare(`
+ INSERT INTO storage_backends
+ (id, name, type, config_json, secret_json, serving_mode, public_url,
+ quota_bytes, enabled, is_default, sort_order)
+ VALUES (?, ?, 's3', '{}', '{}', 'proxy', '', NULL, 0, 0, 1)
+ `).run(S3_BACKEND_ID, 'S3 兼容存储')
+ }
+}
+
+function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendInfo & {
+ secretsMasked: Record
+} {
+ const config = row.type === 's3'
+ ? (parseS3Config(row.config_json) ?? {})
+ : {}
+ let secretsMasked: Record = {}
+ if (row.type === 's3') {
+ const secrets = parseS3Secrets(row.secret_json)
+ secretsMasked = {
+ accessKeyId: secrets?.accessKeyId
+ ? maskSecret(secrets.accessKeyId)
+ : '',
+ secretAccessKey: secrets?.secretAccessKey
+ ? maskSecret(secrets.secretAccessKey)
+ : ''
+ }
+ }
+
+ return {
+ id: row.id,
+ name: row.name,
+ type: row.type,
+ config,
+ servingMode: row.serving_mode,
+ publicUrl: row.public_url,
+ quotaBytes: row.quota_bytes,
+ enabled: row.enabled === 1,
+ isDefault: row.is_default === 1,
+ sortOrder: row.sort_order,
+ secretsMasked
+ }
+}
+
+function maskSecret(value: string): string {
+ if (value.length <= 4) return '****'
+ return `${value.slice(0, 4)}${'*'.repeat(Math.min(8, value.length - 4))}`
+}
+
+export function listStorageBackendRows(): StorageBackendRow[] {
+ ensureDefaultBackends()
+ return getDb().prepare(`
+ SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
+ quota_bytes, enabled, is_default, sort_order
+ FROM storage_backends
+ ORDER BY sort_order ASC, id ASC
+ `).all() as unknown as StorageBackendRow[]
+}
+
+export function listStorageBackends(maskSecrets = true): Array
+}> {
+ return listStorageBackendRows().map(row => rowToInfo(row, maskSecrets))
+}
+
+export function getStorageBackendRow(id: string): StorageBackendRow | null {
+ ensureDefaultBackends()
+ const row = getDb().prepare(`
+ SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
+ quota_bytes, enabled, is_default, sort_order
+ FROM storage_backends
+ WHERE id = ?
+ `).get(id) as StorageBackendRow | undefined
+ return row ?? null
+}
+
+export function getDefaultBackendRow(): StorageBackendRow | null {
+ ensureDefaultBackends()
+ const row = getDb().prepare(`
+ SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
+ quota_bytes, enabled, is_default, sort_order
+ FROM storage_backends
+ WHERE is_default = 1 AND enabled = 1
+ LIMIT 1
+ `).get() as StorageBackendRow | undefined
+ return row ?? null
+}
+
+export function getBackendIdForKey(key: string): string | null {
+ ensureStorageSchema()
+ const row = getDb().prepare(`
+ SELECT backend_id FROM images WHERE key = ?
+ `).get(key) as { backend_id: string } | undefined
+ return row?.backend_id ?? null
+}
+
+export function setDefaultBackend(id: string): void {
+ const db = getDb()
+ db.exec('UPDATE storage_backends SET is_default = 0')
+ db.prepare(`
+ UPDATE storage_backends SET is_default = 1, enabled = 1 WHERE id = ?
+ `).run(id)
+}
+
+export interface StorageBackendPatch {
+ name?: string
+ config?: Record
+ secrets?: { accessKeyId?: string, secretAccessKey?: string }
+ servingMode?: ServingMode
+ publicUrl?: string
+ quotaBytes?: number | null
+ enabled?: boolean
+ isDefault?: boolean
+}
+
+export function updateStorageBackend(id: string, patch: StorageBackendPatch): void {
+ const row = getStorageBackendRow(id)
+ if (!row) {
+ throw new Error(`Storage backend not found: ${id}`)
+ }
+
+ const name = patch.name ?? row.name
+ let configJson = row.config_json
+ let secretJson = row.secret_json
+
+ if (patch.config !== undefined) {
+ const current = row.type === 's3'
+ ? (parseS3Config(row.config_json) ?? {})
+ : {}
+ configJson = JSON.stringify({ ...current, ...patch.config })
+ }
+
+ if (patch.secrets !== undefined && row.type === 's3') {
+ const current = parseS3Secrets(row.secret_json) ?? {
+ accessKeyId: '',
+ secretAccessKey: ''
+ }
+ const next = {
+ accessKeyId: patch.secrets.accessKeyId?.trim()
+ ? patch.secrets.accessKeyId.trim()
+ : current.accessKeyId,
+ secretAccessKey: patch.secrets.secretAccessKey?.trim()
+ ? patch.secrets.secretAccessKey.trim()
+ : current.secretAccessKey
+ }
+ secretJson = JSON.stringify(next)
+ }
+
+ const servingMode = patch.servingMode ?? row.serving_mode
+ const publicUrl = patch.publicUrl ?? row.public_url
+ const quotaBytes = patch.quotaBytes !== undefined
+ ? patch.quotaBytes
+ : row.quota_bytes
+ const enabled = patch.enabled !== undefined
+ ? (patch.enabled ? 1 : 0)
+ : row.enabled
+
+ getDb().prepare(`
+ UPDATE storage_backends
+ SET name = ?, config_json = ?, secret_json = ?, serving_mode = ?,
+ public_url = ?, quota_bytes = ?, enabled = ?
+ WHERE id = ?
+ `).run(name, configJson, secretJson, servingMode, publicUrl, quotaBytes, enabled, id)
+
+ if (patch.isDefault) {
+ setDefaultBackend(id)
+ }
+}
+
+export function isStorageEnvConfigured(): boolean {
+ return getStorageBackendFromEnv() !== null
+}
+
+export function getStorageBackendFromEnv(): {
+ type: StorageBackendType
+ config: Record
+ secrets: { accessKeyId: string, secretAccessKey: string }
+ servingMode: ServingMode
+ publicUrl: string
+} | null {
+ const backendType = process.env.STORAGE_BACKEND?.trim()
+ if (backendType !== 's3') return null
+
+ const endpoint = process.env.S3_ENDPOINT?.trim() ?? ''
+ const region = process.env.S3_REGION?.trim() ?? ''
+ const bucket = process.env.S3_BUCKET?.trim() ?? ''
+ const accessKeyId = process.env.S3_ACCESS_KEY?.trim() ?? ''
+ const secretAccessKey = process.env.S3_SECRET_KEY?.trim() ?? ''
+
+ if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
+ return null
+ }
+
+ return {
+ type: 's3',
+ config: {
+ endpoint,
+ region: region || 'auto',
+ bucket,
+ prefix: process.env.S3_PREFIX?.trim() || undefined,
+ forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true'
+ },
+ secrets: { accessKeyId, secretAccessKey },
+ servingMode: process.env.S3_PUBLIC_URL?.trim() ? 'public' : 'proxy',
+ publicUrl: process.env.S3_PUBLIC_URL?.trim() ?? ''
+ }
+}
+
+export function createBackendInstance(row: StorageBackendRow) {
+ if (row.type === 'local') {
+ return new LocalStorageBackend(
+ row.id,
+ row.serving_mode,
+ row.public_url
+ )
+ }
+
+ const config = parseS3Config(row.config_json)
+ const secrets = parseS3Secrets(row.secret_json)
+ if (!config || !secrets) {
+ throw new Error(`Invalid S3 configuration for backend ${row.id}`)
+ }
+
+ return new S3StorageBackend(
+ row.id,
+ config,
+ secrets,
+ row.serving_mode,
+ row.public_url
+ )
+}
+
+export function createBackendFromEnv() {
+ const env = getStorageBackendFromEnv()
+ if (!env) return null
+
+ return new S3StorageBackend(
+ S3_BACKEND_ID,
+ env.config as unknown as S3BackendConfig,
+ env.secrets,
+ env.servingMode,
+ env.publicUrl
+ )
+}
+
+export function getBackendUsageStats(backendId: string): { count: number, bytes: number } {
+ ensureStorageSchema()
+ const row = getDb().prepare(`
+ SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
+ FROM images
+ WHERE backend_id = ?
+ `).get(backendId) as { count: number, bytes: number }
+ return { count: row.count, bytes: row.bytes }
+}
+
+function getNextSortOrder(): number {
+ const row = getDb().prepare(`
+ SELECT COALESCE(MAX(sort_order), 0) AS max_order FROM storage_backends
+ `).get() as { max_order: number }
+ return row.max_order + 1
+}
+
+export function generateStorageBackendId(): string {
+ const bytes = new Uint8Array(4)
+ crypto.getRandomValues(bytes)
+ const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
+ return `s3-${suffix}`
+}
+
+export function insertStorageBackend(input: {
+ id?: string
+ name: string
+ config: S3BackendConfig
+ secrets: { accessKeyId: string, secretAccessKey: string }
+ servingMode?: ServingMode
+ publicUrl?: string
+ quotaBytes?: number | null
+ enabled?: boolean
+ isDefault?: boolean
+}): string {
+ ensureDefaultBackends()
+ const id = input.id ?? generateStorageBackendId()
+ 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 (?, ?, 's3', ?, ?, ?, ?, ?, ?, 0, ?)
+ `).run(
+ id,
+ input.name,
+ JSON.stringify(input.config),
+ JSON.stringify(input.secrets),
+ input.servingMode ?? 'proxy',
+ input.publicUrl ?? '',
+ input.quotaBytes ?? null,
+ input.enabled === false ? 0 : 1,
+ sortOrder
+ )
+
+ if (input.isDefault) {
+ setDefaultBackend(id)
+ }
+
+ return id
+}
+
+export function deleteStorageBackend(id: string): void {
+ if (id === LOCAL_BACKEND_ID) {
+ throw new Error('Cannot delete local backend')
+ }
+
+ const usage = getBackendUsageStats(id)
+ if (usage.count > 0) {
+ throw new Error('Cannot delete backend with stored images')
+ }
+
+ const row = getStorageBackendRow(id)
+ if (!row) {
+ throw new Error(`Storage backend not found: ${id}`)
+ }
+
+ if (row.is_default) {
+ throw new Error('Cannot delete the default backend')
+ }
+
+ getDb().prepare('DELETE FROM storage_backends WHERE id = ?').run(id)
+}
diff --git a/server/utils/storage-capacity.test.ts b/server/utils/storage-capacity.test.ts
new file mode 100644
index 0000000..f9a3838
--- /dev/null
+++ b/server/utils/storage-capacity.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest'
+import { buildBackendCapacity } from './storage-capacity'
+
+describe('buildBackendCapacity', () => {
+ it('calculates quota-based capacity for s3 backends', async () => {
+ const quotaBytes = 10 * 1024 ** 3
+ const capacity = await buildBackendCapacity('s3-test', 's3', 72_390, quotaBytes)
+ expect(capacity.source).toBe('quota')
+ expect(capacity.totalBytes).toBe(quotaBytes)
+ expect(capacity.usedBytes).toBe(72_390)
+ expect(capacity.freeBytes).toBe(quotaBytes - 72_390)
+ expect(capacity.percent).toBeGreaterThanOrEqual(0)
+ })
+
+ it('allows usage percent to exceed 100 when over quota', async () => {
+ const quotaBytes = 1024
+ const capacity = await buildBackendCapacity('s3-test', 's3', 2048, quotaBytes)
+ expect(capacity.percent).toBe(200)
+ expect(capacity.freeBytes).toBe(0)
+ })
+
+ it('reads disk stats for local backend when available', async () => {
+ const capacity = await buildBackendCapacity('local', 'local', 1024, null)
+ if (capacity.source === 'disk') {
+ expect(capacity.totalBytes).toBeGreaterThan(0)
+ expect(capacity.freeBytes).not.toBeNull()
+ expect(capacity.percent).not.toBeNull()
+ } else {
+ expect(capacity.usedBytes).toBe(1024)
+ }
+ })
+})
diff --git a/server/utils/storage-capacity.ts b/server/utils/storage-capacity.ts
new file mode 100644
index 0000000..aae2dc7
--- /dev/null
+++ b/server/utils/storage-capacity.ts
@@ -0,0 +1,92 @@
+import { statfs } from 'node:fs/promises'
+import { getDataDir } from './data-dir'
+import { LOCAL_BACKEND_ID } from './storage-backends'
+
+export interface StorageCapacity {
+ totalBytes: number | null
+ usedBytes: number
+ freeBytes: number | null
+ percent: number | null
+ source: 'disk' | 'quota'
+}
+
+export function parseQuotaBytes(raw: unknown): number | null {
+ const value = Number(raw)
+ if (!Number.isFinite(value) || value <= 0) return null
+ return Math.round(value)
+}
+
+export async function getLocalVolumeStats(): Promise<{
+ totalBytes: number
+ freeBytes: number
+ usedBytes: number
+} | null> {
+ try {
+ const stats = await statfs(getDataDir())
+ const totalBytes = Number(stats.bsize) * Number(stats.blocks)
+ const freeBytes = Number(stats.bsize) * Number(stats.bavail)
+ if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null
+ return {
+ totalBytes,
+ freeBytes: Math.max(0, freeBytes),
+ usedBytes: Math.max(0, totalBytes - freeBytes)
+ }
+ } catch {
+ return null
+ }
+}
+
+function buildQuotaCapacity(quotaBytes: number, indexBytes: number): StorageCapacity {
+ const usedBytes = indexBytes
+ const freeBytes = Math.max(0, quotaBytes - usedBytes)
+ const percent = Math.round((usedBytes / quotaBytes) * 1000) / 10
+ return {
+ totalBytes: quotaBytes,
+ usedBytes,
+ freeBytes,
+ percent,
+ source: 'quota'
+ }
+}
+
+export async function buildBackendCapacity(
+ backendId: string,
+ backendType: 'local' | 's3',
+ indexBytes: number,
+ quotaBytes: number | null
+): Promise {
+ if (backendType === 's3') {
+ if (quotaBytes && quotaBytes > 0) {
+ return buildQuotaCapacity(quotaBytes, indexBytes)
+ }
+ return {
+ totalBytes: null,
+ usedBytes: indexBytes,
+ freeBytes: null,
+ percent: null,
+ source: 'quota'
+ }
+ }
+
+ if (backendId === LOCAL_BACKEND_ID || backendType === 'local') {
+ const volume = await getLocalVolumeStats()
+ if (volume) {
+ const percent = Math.round((volume.usedBytes / volume.totalBytes) * 1000) / 10
+ return {
+ totalBytes: volume.totalBytes,
+ usedBytes: volume.usedBytes,
+ freeBytes: volume.freeBytes,
+ percent,
+ source: 'disk'
+ }
+ }
+ }
+
+ return {
+ totalBytes: null,
+ usedBytes: indexBytes,
+ freeBytes: null,
+ percent: null,
+ source: 'disk'
+ }
+}
diff --git a/server/utils/storage.ts b/server/utils/storage.ts
index 103ce1b..de77434 100644
--- a/server/utils/storage.ts
+++ b/server/utils/storage.ts
@@ -1,314 +1,119 @@
-import { createReadStream, promises as fs } from 'node:fs'
import type { ReadStream } from 'node:fs'
-import { dirname, join } from 'node:path'
-import { isValidFolderName, validateImageKey, DEFAULT_FOLDER } from './image-key'
+import type { Readable } from 'node:stream'
+import {
+ countImages as countImagesFromIndex,
+ deleteImageIndex,
+ getFolderStorageStats as getFolderStorageStatsFromIndex,
+ getImageIndexAsStored,
+ getUserScopedStorageStats as getUserScopedStorageStatsFromIndex,
+ insertImageIndex,
+ listFolders as listFoldersFromIndex,
+ listFoldersForUser as listFoldersForUserFromIndex,
+ listImageKeysForAutoDelete,
+ listImages as listImagesFromIndex,
+ searchImages as searchImagesFromIndex
+} from './image-index'
+import {
+ getActiveBackend,
+ getBackendForKey
+} from './storage/resolver'
+import type {
+ FolderStorageStat,
+ PaginatedResult,
+ StoredImage,
+ StoredImageMeta
+} from './storage/types'
-/**
- * 本地文件系统存储层
- *
- * 目录布局(DATA_DIR 默认 ./data,容器内挂载 /data):
- * //YYYY/MM/<随机ID>.webp 按上传 type 分目录(默认 images)
- * /twikoo/YYYY/MM/<随机ID>.webp twikoo 评论图片
- * //YYYY/MM/<随机ID>.webp.meta.json 元数据(原始文件名等)
- *
- * 列表按日期降序(新月份在前),meta 缺失时回退到文件属性。
- */
+export type {
+ FolderStorageStat,
+ PaginatedResult,
+ StoredImage,
+ StoredImageMeta
+} from './storage/types'
-export interface StoredImageMeta {
- originalName: string
- uploadedAt: string
- contentType: string
- size: number
- userId?: number | null
-}
-
-export interface StoredImage extends StoredImageMeta {
- key: string
- mtimeMs: number
-}
-
-const META_SUFFIX = '.meta.json'
-
-export function getDataDir(): string {
- return process.env.DATA_DIR || join(process.cwd(), 'data')
-}
-
-function keyToFilePath(key: string): string {
- // key 均已通过 validateImageKey 校验,不存在路径穿越
- return join(getDataDir(), ...key.split('/'))
-}
-
-function contentTypeFromKey(key: string): string {
- const ext = key.split('.').pop()?.toLowerCase()
- switch (ext) {
- case 'jpg':
- case 'jpeg':
- return 'image/jpeg'
- case 'png':
- return 'image/png'
- case 'gif':
- return 'image/gif'
- case 'webp':
- return 'image/webp'
- case 'svg':
- return 'image/svg+xml'
- case 'ico':
- return 'image/x-icon'
- default:
- return 'application/octet-stream'
- }
-}
-
-async function readMeta(key: string): Promise> {
- try {
- const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
- return JSON.parse(raw) as Partial
- } catch {
- return {}
- }
-}
+export { getDataDir } from './data-dir'
export async function putImage(
key: string,
bytes: Uint8Array,
meta: StoredImageMeta
): Promise {
- const filePath = keyToFilePath(key)
- await fs.mkdir(dirname(filePath), { recursive: true })
- await fs.writeFile(filePath, bytes)
- await fs.writeFile(filePath + META_SUFFIX, JSON.stringify(meta))
+ const backend = await getActiveBackend()
+ await backend.put(key, bytes, meta)
+ insertImageIndex({
+ key,
+ backendId: backend.id,
+ userId: meta.userId,
+ originalName: meta.originalName,
+ contentType: meta.contentType,
+ size: meta.size,
+ uploadedAt: meta.uploadedAt
+ })
}
export async function headImage(key: string): Promise {
- if (!validateImageKey(key)) return null
+ const indexed = getImageIndexAsStored(key)
+ if (indexed) return indexed
- let stat
- try {
- stat = await fs.stat(keyToFilePath(key))
- } catch {
- return null
- }
- if (!stat.isFile()) return null
+ const backend = await getBackendForKey(key)
+ const stored = await backend.head(key)
+ if (!stored) return null
- const meta = await readMeta(key)
- return {
- key,
- size: stat.size,
- mtimeMs: stat.mtimeMs,
- contentType: meta.contentType ?? contentTypeFromKey(key),
- originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
- uploadedAt: meta.uploadedAt ?? stat.mtime.toISOString(),
- userId: meta.userId ?? null
- }
+ insertImageIndex({
+ key: stored.key,
+ backendId: backend.id,
+ userId: stored.userId,
+ originalName: stored.originalName,
+ contentType: stored.contentType,
+ size: stored.size,
+ uploadedAt: stored.uploadedAt
+ })
+
+ return { ...stored, backendId: backend.id }
}
-export function createImageStream(
+export async function createImageStream(
key: string,
range?: { start: number, end: number }
-): ReadStream {
- return createReadStream(keyToFilePath(key), range)
+): Promise {
+ const backend = await getBackendForKey(key)
+ return backend.createStream(key, range)
}
export async function deleteImage(key: string): Promise {
- const filePath = keyToFilePath(key)
- await fs.unlink(filePath)
- await fs.unlink(filePath + META_SUFFIX).catch(() => {
- // meta 文件可能不存在(外部导入的历史图片)
- })
-}
-
-export interface PaginatedResult {
- items: T[]
- total: number
- page: number
- pageSize: number
- totalPages: number
+ const backend = await getBackendForKey(key)
+ await backend.delete(key)
+ deleteImageIndex(key)
}
export async function listFolders(): Promise {
- const names = await readDirNames(getDataDir())
- const folders = names.filter(isValidFolderName)
- if (!folders.includes(DEFAULT_FOLDER)) {
- folders.push(DEFAULT_FOLDER)
- }
- return folders.sort((a, b) => a.localeCompare(b))
+ return listFoldersFromIndex()
}
-/** 遍历各 type 目录下的 YYYY/MM,返回按日期降序的 key 列表(新月份在前) */
-export async function listImageKeys(folder?: string): Promise {
- const keys: string[] = []
- const prefixes = folder && isValidFolderName(folder)
- ? [folder]
- : await listFolders()
-
- for (const prefix of prefixes) {
- const root = join(getDataDir(), prefix)
- const years = await readDirNames(root)
- for (const year of years) {
- const months = await readDirNames(join(root, year))
- for (const month of months) {
- const files = await readFileNames(join(root, year, month))
- for (const file of files) {
- if (file.endsWith(META_SUFFIX)) continue
- const key = `${prefix}/${year}/${month}/${file}`
- if (validateImageKey(key)) keys.push(key)
- }
- }
- }
- }
-
- // 按 YYYY/MM/文件名 降序排,使不同 type 按时间交错而不是分块
- return keys.sort((a, b) => {
- const aDate = a.slice(a.indexOf('/') + 1)
- const bDate = b.slice(b.indexOf('/') + 1)
- return bDate.localeCompare(aDate) || b.localeCompare(a)
- })
-}
-
-async function readDirNames(path: string): Promise {
- try {
- const entries = await fs.readdir(path, { withFileTypes: true })
- return entries.filter(e => e.isDirectory()).map(e => e.name)
- } catch {
- return []
- }
-}
-
-function matchesUserFilter(
- meta: StoredImageMeta,
- userFilter: number | 'admin'
-): boolean {
- if (userFilter === 'admin') return true
- const ownerId = meta.userId ?? null
- if (ownerId === null) return false
- return ownerId === userFilter
-}
-
-async function filterKeysByUser(
- keys: string[],
- userFilter?: number | 'admin'
-): Promise {
- if (userFilter === undefined || userFilter === 'admin') {
- return keys
- }
-
- const filtered: string[] = []
- for (const key of keys) {
- const meta = await readMeta(key)
- if (matchesUserFilter(meta as StoredImageMeta, userFilter)) {
- filtered.push(key)
- }
- }
- return filtered
-}
-
-async function readFileNames(path: string): Promise {
- try {
- const entries = await fs.readdir(path, { withFileTypes: true })
- return entries.filter(e => e.isFile()).map(e => e.name)
- } catch {
- return []
- }
-}
-
-function paginateSlice(
- all: T[],
- limit: number,
- page?: number
-): PaginatedResult {
- const total = all.length
- const pageSize = limit
- const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
- const requestedPage = Math.max(1, page ?? 1)
- const safePage = Math.min(requestedPage, totalPages)
- const start = (safePage - 1) * pageSize
- const items = all.slice(start, start + pageSize)
-
- return {
- items,
- total,
- page: safePage,
- pageSize,
- totalPages
- }
+export async function listImageKeys(): Promise {
+ const images = await listImageKeysForAutoDelete()
+ return images.map(image => image.key)
}
export async function countImages(
folder?: string,
userFilter?: number | 'admin'
): Promise {
- let keys = await listImageKeys(folder)
- keys = await filterKeysByUser(keys, userFilter)
- return keys.length
+ return countImagesFromIndex(folder, userFilter)
}
-export interface FolderStorageStat {
- folder: string
- count: number
- bytes: number
-}
-
-/** 按顶层目录统计当前磁盘上的图片数量与体积 */
export async function getFolderStorageStats(
userFilter?: number | 'admin'
): Promise {
- let keys = await listImageKeys()
- keys = await filterKeysByUser(keys, userFilter)
- const map = new Map()
-
- for (const key of keys) {
- const folder = key.split('/')[0] ?? 'unknown'
- const meta = await headImage(key)
- if (!meta) continue
- const current = map.get(folder) ?? { count: 0, bytes: 0 }
- current.count += 1
- current.bytes += meta.size
- map.set(folder, current)
- }
-
- return [...map.entries()]
- .map(([folder, stat]) => ({ folder, ...stat }))
- .sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
+ return getFolderStorageStatsFromIndex(userFilter)
}
export async function listFoldersForUser(
userFilter?: number | 'admin'
): Promise {
- if (userFilter === undefined || userFilter === 'admin') {
- return listFolders()
- }
-
- let keys = await listImageKeys()
- keys = await filterKeysByUser(keys, userFilter)
- const folders = new Set()
- for (const key of keys) {
- const folder = key.split('/')[0]
- if (folder && isValidFolderName(folder)) {
- folders.add(folder)
- }
- }
- if (!folders.size) {
- folders.add(DEFAULT_FOLDER)
- }
- return [...folders].sort((a, b) => a.localeCompare(b))
+ return listFoldersForUserFromIndex(userFilter)
}
-function startOfDayIsoInShanghai(now = new Date()): string {
- const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
- const y = shifted.getUTCFullYear()
- const m = shifted.getUTCMonth()
- const d = shifted.getUTCDate()
- return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
-}
-
-function startOfMonthIsoInShanghai(now = new Date()): string {
- const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
- const y = shifted.getUTCFullYear()
- const m = shifted.getUTCMonth()
- return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
-}
-
-/** 普通用户统计:基于本人图片 meta,不依赖全局 activity_logs */
export async function getUserScopedStorageStats(userId: number): Promise<{
storedCount: number
uploadBytesTotal: number
@@ -316,42 +121,7 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
uploadMonth: number
byFolder: FolderStorageStat[]
}> {
- let keys = await listImageKeys()
- keys = await filterKeysByUser(keys, userId)
-
- const startOfDay = startOfDayIsoInShanghai()
- const startOfMonth = startOfMonthIsoInShanghai()
- const map = new Map()
- let uploadBytesTotal = 0
- let uploadToday = 0
- let uploadMonth = 0
-
- for (const key of keys) {
- const meta = await headImage(key)
- if (!meta) continue
-
- uploadBytesTotal += meta.size
- if (meta.uploadedAt >= startOfDay) uploadToday += 1
- if (meta.uploadedAt >= startOfMonth) uploadMonth += 1
-
- const folder = key.split('/')[0] ?? 'unknown'
- const current = map.get(folder) ?? { count: 0, bytes: 0 }
- current.count += 1
- current.bytes += meta.size
- map.set(folder, current)
- }
-
- const byFolder = [...map.entries()]
- .map(([folder, stat]) => ({ folder, ...stat }))
- .sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
-
- return {
- storedCount: keys.length,
- uploadBytesTotal,
- uploadToday,
- uploadMonth,
- byFolder
- }
+ return getUserScopedStorageStatsFromIndex(userId)
}
export async function listImages(options: {
@@ -360,16 +130,7 @@ export async function listImages(options: {
folder?: string
userFilter?: number | 'admin'
}): Promise> {
- let keys = await listImageKeys(options.folder)
- keys = await filterKeysByUser(keys, options.userFilter)
- const sliced = paginateSlice(keys, options.limit, options.page)
- const items = (await Promise.all(sliced.items.map(headImage)))
- .filter((item): item is StoredImage => item !== null)
-
- return {
- ...sliced,
- items
- }
+ return listImagesFromIndex(options)
}
export async function searchImages(options: {
@@ -379,20 +140,5 @@ export async function searchImages(options: {
folder?: string
userFilter?: number | 'admin'
}): Promise> {
- const needle = options.query.trim().toLowerCase()
- let keys = await listImageKeys(options.folder)
- keys = await filterKeysByUser(keys, options.userFilter)
- const matches: StoredImage[] = []
-
- for (const key of keys) {
- const keyMatches = key.toLowerCase().includes(needle)
- const item = await headImage(key)
- if (!item) continue
-
- if (keyMatches || item.originalName.toLowerCase().includes(needle)) {
- matches.push(item)
- }
- }
-
- return paginateSlice(matches, options.limit, options.page)
+ return searchImagesFromIndex(options)
}
diff --git a/server/utils/storage/index.ts b/server/utils/storage/index.ts
new file mode 100644
index 0000000..90362ad
--- /dev/null
+++ b/server/utils/storage/index.ts
@@ -0,0 +1,3 @@
+export { getActiveBackend, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver'
+export { LocalStorageBackend } from './local'
+export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3'
diff --git a/server/utils/storage/local.ts b/server/utils/storage/local.ts
new file mode 100644
index 0000000..8d4d22d
--- /dev/null
+++ b/server/utils/storage/local.ts
@@ -0,0 +1,116 @@
+import { createReadStream, promises as fs } from 'node:fs'
+import type { ReadStream } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { getDataDir } from '../data-dir'
+import { validateImageKey } from '../image-key'
+import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
+
+const META_SUFFIX = '.meta.json'
+
+function keyToFilePath(key: string): string {
+ return join(getDataDir(), ...key.split('/'))
+}
+
+function contentTypeFromKey(key: string): string {
+ const ext = key.split('.').pop()?.toLowerCase()
+ switch (ext) {
+ case 'jpg':
+ case 'jpeg':
+ return 'image/jpeg'
+ case 'png':
+ return 'image/png'
+ case 'gif':
+ return 'image/gif'
+ case 'webp':
+ return 'image/webp'
+ case 'svg':
+ return 'image/svg+xml'
+ case 'ico':
+ return 'image/x-icon'
+ default:
+ return 'application/octet-stream'
+ }
+}
+
+async function readMeta(key: string): Promise> {
+ try {
+ const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
+ return JSON.parse(raw) as Partial
+ } catch {
+ return {}
+ }
+}
+
+export class LocalStorageBackend implements StorageBackend {
+ readonly id: string
+ readonly type = 'local' as const
+ readonly servingMode: 'proxy' | 'public'
+ readonly publicUrl: string
+
+ constructor(
+ id = 'local',
+ servingMode: 'proxy' | 'public' = 'proxy',
+ publicUrl = ''
+ ) {
+ this.id = id
+ this.servingMode = servingMode
+ this.publicUrl = publicUrl
+ }
+
+ async put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise {
+ const filePath = keyToFilePath(key)
+ await fs.mkdir(dirname(filePath), { recursive: true })
+ await fs.writeFile(filePath, bytes)
+ await fs.writeFile(filePath + META_SUFFIX, JSON.stringify(meta))
+ }
+
+ async head(key: string): Promise {
+ if (!validateImageKey(key)) return null
+
+ let stat
+ try {
+ stat = await fs.stat(keyToFilePath(key))
+ } catch {
+ return null
+ }
+ if (!stat.isFile()) return null
+
+ const meta = await readMeta(key)
+ return {
+ key,
+ size: stat.size,
+ mtimeMs: stat.mtimeMs,
+ contentType: meta.contentType ?? contentTypeFromKey(key),
+ originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
+ uploadedAt: meta.uploadedAt ?? stat.mtime.toISOString(),
+ userId: meta.userId ?? null,
+ backendId: this.id
+ }
+ }
+
+ async delete(key: string): Promise {
+ const filePath = keyToFilePath(key)
+ await fs.unlink(filePath)
+ await fs.unlink(filePath + META_SUFFIX).catch(() => {})
+ }
+
+ async createStream(
+ key: string,
+ range?: { start: number, end: number }
+ ): Promise {
+ return createReadStream(keyToFilePath(key), range)
+ }
+
+ async testConnection(): Promise<{ ok: boolean, message: string }> {
+ try {
+ await fs.access(getDataDir())
+ return { ok: true, message: '本地磁盘可访问' }
+ } catch {
+ return { ok: false, message: '无法访问数据目录' }
+ }
+ }
+
+ async getUsageStats(): Promise<{ count: number, bytes: number }> {
+ return { count: 0, bytes: 0 }
+ }
+}
diff --git a/server/utils/storage/resolver.test.ts b/server/utils/storage/resolver.test.ts
new file mode 100644
index 0000000..cb79bc9
--- /dev/null
+++ b/server/utils/storage/resolver.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest'
+import { LOCAL_BACKEND_ID } from '../storage-backends'
+
+describe('storage resolver constants', () => {
+ it('uses stable local backend id', () => {
+ expect(LOCAL_BACKEND_ID).toBe('local')
+ })
+})
+
+describe('default backend routing', () => {
+ it('returns local when no env override and local is default', async () => {
+ const original = process.env.STORAGE_BACKEND
+ delete process.env.STORAGE_BACKEND
+
+ const { setDefaultBackend, LOCAL_BACKEND_ID } = await import('../storage-backends')
+ setDefaultBackend(LOCAL_BACKEND_ID)
+
+ const { getActiveBackend } = await import('./resolver')
+ const backend = await getActiveBackend()
+ expect(backend.type).toBe('local')
+
+ if (original !== undefined) {
+ process.env.STORAGE_BACKEND = original
+ }
+ })
+})
diff --git a/server/utils/storage/resolver.ts b/server/utils/storage/resolver.ts
new file mode 100644
index 0000000..6859977
--- /dev/null
+++ b/server/utils/storage/resolver.ts
@@ -0,0 +1,115 @@
+import {
+ createBackendFromEnv,
+ createBackendInstance,
+ getBackendIdForKey,
+ getDefaultBackendRow,
+ getStorageBackendRow,
+ LOCAL_BACKEND_ID,
+ ensureDefaultBackends
+} from '../storage-backends'
+import type { StorageBackend, StorageBackendRow } from './types'
+
+export async function getActiveBackend(): Promise {
+ const fromEnv = createBackendFromEnv()
+ if (fromEnv) return fromEnv
+
+ ensureDefaultBackends()
+ const row = getDefaultBackendRow()
+ if (!row) {
+ return createBackendInstance({
+ id: LOCAL_BACKEND_ID,
+ name: '本地磁盘',
+ type: 'local',
+ config_json: '{}',
+ secret_json: '{}',
+ serving_mode: 'proxy',
+ public_url: '',
+ quota_bytes: null,
+ enabled: 1,
+ is_default: 1,
+ sort_order: 0
+ })
+ }
+
+ return createBackendInstance(row)
+}
+
+export async function getBackendForKey(key: string): Promise {
+ const fromEnv = createBackendFromEnv()
+ if (fromEnv) return fromEnv
+
+ ensureDefaultBackends()
+ const backendId = getBackendIdForKey(key) ?? LOCAL_BACKEND_ID
+ const row = getStorageBackendRow(backendId)
+
+ if (!row || !row.enabled) {
+ return createBackendInstance(fallbackLocalRow())
+ }
+
+ try {
+ return createBackendInstance(row)
+ } catch {
+ return createBackendInstance(fallbackLocalRow())
+ }
+}
+
+export function getActiveBackendRow(): StorageBackendRow | null {
+ const fromEnv = createBackendFromEnv()
+ if (fromEnv) {
+ return {
+ id: fromEnv.id,
+ name: 'S3 (环境变量)',
+ type: 's3',
+ config_json: '{}',
+ secret_json: '{}',
+ serving_mode: fromEnv.servingMode,
+ public_url: fromEnv.publicUrl,
+ quota_bytes: null,
+ enabled: 1,
+ is_default: 1,
+ sort_order: 0
+ }
+ }
+
+ ensureDefaultBackends()
+ return getDefaultBackendRow()
+}
+
+export async function getBackendRowForKey(key: string): Promise {
+ const fromEnv = createBackendFromEnv()
+ if (fromEnv) {
+ return {
+ id: fromEnv.id,
+ name: 'S3 (环境变量)',
+ type: 's3',
+ config_json: '{}',
+ secret_json: '{}',
+ serving_mode: fromEnv.servingMode,
+ public_url: fromEnv.publicUrl,
+ quota_bytes: null,
+ enabled: 1,
+ is_default: 1,
+ sort_order: 0
+ }
+ }
+
+ ensureDefaultBackends()
+ const backendId = getBackendIdForKey(key) ?? LOCAL_BACKEND_ID
+ return getStorageBackendRow(backendId) ?? fallbackLocalRow()
+}
+
+function fallbackLocalRow(): StorageBackendRow {
+ return {
+ id: LOCAL_BACKEND_ID,
+ name: '本地磁盘',
+ type: 'local',
+ config_json: '{}',
+ secret_json: '{}',
+ serving_mode: 'proxy',
+ public_url: '',
+ quota_bytes: null,
+ enabled: 1,
+ is_default: 1,
+ sort_order: 0
+ }
+}
diff --git a/server/utils/storage/s3.test.ts b/server/utils/storage/s3.test.ts
new file mode 100644
index 0000000..a4e8a46
--- /dev/null
+++ b/server/utils/storage/s3.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+import { buildObjectKey, parseS3Config, validateS3Config } from './s3'
+
+describe('S3 backend helpers', () => {
+ it('buildObjectKey joins prefix and key', () => {
+ expect(buildObjectKey('pichost', 'images/2026/01/a.webp')).toBe(
+ 'pichost/images/2026/01/a.webp'
+ )
+ expect(buildObjectKey(undefined, 'images/a.webp')).toBe('images/a.webp')
+ expect(buildObjectKey('/pichost/', 'images/a.webp')).toBe('pichost/images/a.webp')
+ })
+
+ it('parseS3Config requires endpoint, region and bucket', () => {
+ expect(parseS3Config('{}')).toBeNull()
+ expect(parseS3Config(JSON.stringify({
+ endpoint: 'https://example.r2.cloudflarestorage.com',
+ region: 'auto',
+ bucket: 'pics'
+ }))).toEqual({
+ endpoint: 'https://example.r2.cloudflarestorage.com',
+ region: 'auto',
+ bucket: 'pics',
+ prefix: undefined,
+ forcePathStyle: false
+ })
+ })
+
+ it('validateS3Config rejects invalid endpoint', () => {
+ expect(validateS3Config({
+ endpoint: 'not-a-url',
+ region: 'auto',
+ bucket: 'pics'
+ })).toBeTruthy()
+ expect(validateS3Config({
+ endpoint: 'https://example.com',
+ region: 'auto',
+ bucket: ''
+ })).toBeTruthy()
+ expect(validateS3Config({
+ endpoint: 'https://example.com',
+ region: 'auto',
+ bucket: 'pics'
+ })).toBeNull()
+ })
+})
diff --git a/server/utils/storage/s3.ts b/server/utils/storage/s3.ts
new file mode 100644
index 0000000..f72263d
--- /dev/null
+++ b/server/utils/storage/s3.ts
@@ -0,0 +1,218 @@
+import {
+ DeleteObjectCommand,
+ GetObjectCommand,
+ HeadBucketCommand,
+ HeadObjectCommand,
+ PutObjectCommand,
+ S3Client
+} from '@aws-sdk/client-s3'
+import type { Readable } from 'node:stream'
+import { validateImageKey } from '../image-key'
+import type {
+ S3BackendConfig,
+ S3BackendSecrets,
+ ServingMode,
+ StoredImage,
+ StoredImageMeta,
+ StorageBackend
+} from './types'
+
+function contentTypeFromKey(key: string): string {
+ const ext = key.split('.').pop()?.toLowerCase()
+ switch (ext) {
+ case 'jpg':
+ case 'jpeg':
+ return 'image/jpeg'
+ case 'png':
+ return 'image/png'
+ case 'gif':
+ return 'image/gif'
+ case 'webp':
+ return 'image/webp'
+ case 'svg':
+ return 'image/svg+xml'
+ case 'ico':
+ return 'image/x-icon'
+ default:
+ return 'application/octet-stream'
+ }
+}
+
+export function buildObjectKey(prefix: string | undefined, key: string): string {
+ const trimmed = prefix?.replace(/^\/+|\/+$/g, '') ?? ''
+ return trimmed ? `${trimmed}/${key}` : key
+}
+
+export function parseS3Config(raw: string): S3BackendConfig | null {
+ try {
+ const parsed = JSON.parse(raw) as Partial
+ if (!parsed.endpoint || !parsed.region || !parsed.bucket) return null
+ return {
+ endpoint: parsed.endpoint,
+ region: parsed.region,
+ bucket: parsed.bucket,
+ prefix: parsed.prefix,
+ forcePathStyle: parsed.forcePathStyle ?? false
+ }
+ } catch {
+ return null
+ }
+}
+
+export function parseS3Secrets(raw: string): S3BackendSecrets | null {
+ try {
+ const parsed = JSON.parse(raw) as Partial
+ if (!parsed.accessKeyId || !parsed.secretAccessKey) return null
+ return {
+ accessKeyId: parsed.accessKeyId,
+ secretAccessKey: parsed.secretAccessKey
+ }
+ } catch {
+ return null
+ }
+}
+
+export function validateS3Config(config: S3BackendConfig): string | null {
+ if (!config.endpoint.startsWith('http')) {
+ return 'Endpoint 需为 http(s) 地址'
+ }
+ if (!config.bucket.trim()) {
+ return 'Bucket 不能为空'
+ }
+ return null
+}
+
+export class S3StorageBackend implements StorageBackend {
+ readonly id: string
+ readonly type = 's3' as const
+ readonly servingMode: ServingMode
+ readonly publicUrl: string
+ private readonly client: S3Client
+ private readonly bucket: string
+ private readonly prefix: string | undefined
+
+ constructor(
+ id: string,
+ config: S3BackendConfig,
+ secrets: S3BackendSecrets,
+ servingMode: ServingMode = 'proxy',
+ publicUrl = ''
+ ) {
+ this.id = id
+ this.servingMode = servingMode
+ this.publicUrl = publicUrl
+ this.bucket = config.bucket
+ this.prefix = config.prefix
+ this.client = new S3Client({
+ endpoint: config.endpoint,
+ region: config.region,
+ credentials: {
+ accessKeyId: secrets.accessKeyId,
+ secretAccessKey: secrets.secretAccessKey
+ },
+ forcePathStyle: config.forcePathStyle ?? false
+ })
+ }
+
+ private objectKey(key: string): string {
+ return buildObjectKey(this.prefix, key)
+ }
+
+ async put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise {
+ await this.client.send(new PutObjectCommand({
+ Bucket: this.bucket,
+ Key: this.objectKey(key),
+ Body: bytes,
+ ContentType: meta.contentType,
+ Metadata: {
+ originalName: meta.originalName,
+ uploadedAt: meta.uploadedAt,
+ userId: meta.userId != null ? String(meta.userId) : ''
+ }
+ }))
+ }
+
+ async head(key: string): Promise {
+ if (!validateImageKey(key)) return null
+
+ try {
+ const response = await this.client.send(new HeadObjectCommand({
+ Bucket: this.bucket,
+ Key: this.objectKey(key)
+ }))
+
+ const uploadedAt = response.Metadata?.uploadedat
+ ?? response.LastModified?.toISOString()
+ ?? new Date().toISOString()
+ const originalName = response.Metadata?.originalname
+ ?? key.split('/').pop()
+ ?? 'image'
+ const userIdRaw = response.Metadata?.userid
+ const userId = userIdRaw ? Number(userIdRaw) : null
+
+ return {
+ key,
+ size: response.ContentLength ?? 0,
+ mtimeMs: response.LastModified?.getTime() ?? Date.now(),
+ contentType: response.ContentType ?? contentTypeFromKey(key),
+ originalName,
+ uploadedAt,
+ userId: Number.isFinite(userId) ? userId : null,
+ backendId: this.id
+ }
+ } catch {
+ return null
+ }
+ }
+
+ async delete(key: string): Promise {
+ await this.client.send(new DeleteObjectCommand({
+ Bucket: this.bucket,
+ Key: this.objectKey(key)
+ }))
+ }
+
+ async createStream(
+ key: string,
+ range?: { start: number, end: number }
+ ): Promise {
+ const response = await this.client.send(new GetObjectCommand({
+ Bucket: this.bucket,
+ Key: this.objectKey(key),
+ Range: range ? `bytes=${range.start}-${range.end}` : undefined
+ }))
+
+ if (!response.Body) {
+ throw new Error('Empty S3 response body')
+ }
+
+ return response.Body as Readable
+ }
+
+ async testConnection(): Promise<{ ok: boolean, message: string }> {
+ const probeKey = `__pichost-probe-${Date.now()}.txt`
+ const probeBytes = new TextEncoder().encode('pichost-probe')
+
+ try {
+ await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
+ await this.client.send(new PutObjectCommand({
+ Bucket: this.bucket,
+ Key: buildObjectKey(this.prefix, probeKey),
+ Body: probeBytes,
+ ContentType: 'text/plain'
+ }))
+ await this.client.send(new DeleteObjectCommand({
+ Bucket: this.bucket,
+ Key: buildObjectKey(this.prefix, probeKey)
+ }))
+ return { ok: true, message: '连接成功' }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '连接失败'
+ return { ok: false, message }
+ }
+ }
+
+ async getUsageStats(): Promise<{ count: number, bytes: number }> {
+ return { count: 0, bytes: 0 }
+ }
+}
diff --git a/server/utils/storage/types.ts b/server/utils/storage/types.ts
new file mode 100644
index 0000000..7e89538
--- /dev/null
+++ b/server/utils/storage/types.ts
@@ -0,0 +1,85 @@
+import type { Readable } from 'node:stream'
+
+export interface StoredImageMeta {
+ originalName: string
+ uploadedAt: string
+ contentType: string
+ size: number
+ userId?: number | null
+}
+
+export interface StoredImage extends StoredImageMeta {
+ key: string
+ mtimeMs: number
+ backendId?: string
+}
+
+export interface PaginatedResult {
+ items: T[]
+ total: number
+ page: number
+ pageSize: number
+ totalPages: number
+}
+
+export interface FolderStorageStat {
+ folder: string
+ count: number
+ bytes: number
+}
+
+export type StorageBackendType = 'local' | 's3'
+export type ServingMode = 'proxy' | 'public'
+
+export interface S3BackendConfig {
+ endpoint: string
+ region: string
+ bucket: string
+ prefix?: string
+ forcePathStyle?: boolean
+}
+
+export interface S3BackendSecrets {
+ accessKeyId: string
+ secretAccessKey: string
+}
+
+export interface StorageBackendRow {
+ id: string
+ name: string
+ type: StorageBackendType
+ config_json: string
+ secret_json: string
+ serving_mode: ServingMode
+ public_url: string
+ quota_bytes: number | null
+ enabled: number
+ is_default: number
+ sort_order: number
+}
+
+export interface StorageBackendInfo {
+ id: string
+ name: string
+ type: StorageBackendType
+ config: S3BackendConfig | Record
+ servingMode: ServingMode
+ publicUrl: string
+ quotaBytes: number | null
+ enabled: boolean
+ isDefault: boolean
+ sortOrder: number
+}
+
+export interface StorageBackend {
+ readonly id: string
+ readonly type: StorageBackendType
+ readonly servingMode: ServingMode
+ readonly publicUrl: string
+ put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise
+ head(key: string): Promise
+ delete(key: string): Promise
+ createStream(key: string, range?: { start: number, end: number }): Promise
+ testConnection(): Promise<{ ok: boolean, message: string }>
+ getUsageStats(): Promise<{ count: number, bytes: number }>
+}