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

version @@ -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 | -| :----------------------------------: | :------------------------------------: | -| ![Login](docs/screenshots/login.png) | ![Upload](docs/screenshots/upload.png) | +| Upload | Storage | +| :------------------------------------: | :--------------------------------: | +| ![Upload](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) | -| Upload preferences | Stats & gallery | -| :----------------------------------------------: | :----------------------------------: | -| ![Preferences](docs/screenshots/preferences.png) | ![Stats](docs/screenshots/stats.png) | - -| Settings | | -| :----------------------------------------: | :-: | -| ![Settings](docs/screenshots/settings.png) | | +| Stats & gallery | Settings | +| :----------------------------------: | :--------------------------------: | +| ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings.png) | ## 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 · 本地磁盘或对象存储

version @@ -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 +``` + +--- + ## 界面预览 -| 登录 | 上传 | -| :---------------------------------: | :----------------------------------: | -| ![登录](docs/screenshots/login.png) | ![上传](docs/screenshots/upload.png) | +| 上传 | 存储管理 | +| :-----------------------------------: | :-------------------------------------: | +| ![上传](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) | -| 上传偏好 | 统计与图库 | -| :-------------------------------------------: | :---------------------------------: | -| ![上传偏好](docs/screenshots/preferences.png) | ![统计](docs/screenshots/stats.png) | - -| 系统设置 | | -| :------------------------------------: | :-: | -| ![设置](docs/screenshots/settings.png) | | +| 统计与图库 | 系统设置 | +| :---------------------------------: | :-------------------------------------: | +| ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) | ## 特性 - **拖拽 / 点击 / 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 @@ + + 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 @@ + + + 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 @@ + + + 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 `![${safeAlt}](${url})` @@ -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 }> +}