1 Commits
Author SHA1 Message Date
O96u f8921837af release: v1.1.4 — storage path layout and bidirectional domain isolation
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Bump version to 1.1.4. Add flat vs date storage paths, basename-only
hide-folder URLs, and block image paths on the site host when domain
separation is active.
2026-08-28 09:58:28 +08:00
52 changed files with 945 additions and 1329 deletions

No files matched your search

+2 -32
View File
@@ -2,35 +2,6 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [1.2.0] - 2026-08-28
### 变更
- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移后升级
### 新增
- CLI 迁移工具:`docker exec pichost migrate` / `migrate --apply`(`server/cli/migrate-to-single-images.mjs`)
- 扫描 `data/` 下除 `images` 外所有顶层目录中的图片,输出 `data/mapping.json`
- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录),并打印同步日志
- 迁移文档:[`docs/migration-to-v1.2.md`](docs/migration-to-v1.2.md)
### 改进
- 启动时归一化遗留 SQLite key、清理无文件的孤儿索引;支持删除仅索引无文件的记录
## [1.1.5] - 2026-08-28
### 修复
- 修复 API 上传后图库预览偶发失败、需等待刷新才显示的问题
- 图库卡片加载失败后自动重试,刷新列表时重置预览状态
### 改进
- 出图前校验存储文件实际存在,避免索引与磁盘短暂不一致
- 图片 404/403 与域名隔离 404 禁止 CDN 缓存,降低反代误判影响
## [1.1.4] - 2026-08-28 ## [1.1.4] - 2026-08-28
### 新增 ### 新增
@@ -140,7 +111,6 @@
- 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单 - 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单
- 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户) - 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户)
- Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`) - Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`)
- Docker 升级到 v1.2.0 前迁移遗留目录:`docker exec pichost migrate` / `migrate --apply`
### 改进 ### 改进
@@ -177,9 +147,9 @@
### 路线图 ### 路线图
- **v1.2.0**(开发中):统一 `images/` 存储、遗留目录迁移 CLI、启动索引同步 - **v1.1.2**(当前 `main`):操作日志、图库存储筛选、上传限流
[1.2.0]: https://github.com/O96u/PicHost/releases/tag/v1.2.0 [1.1.2]: https://github.com/O96u/PicHost/releases/tag/v1.1.2
[1.1.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1 [1.1.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1
[1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0 [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.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4
-1
View File
@@ -24,7 +24,6 @@ ENV NODE_ENV=production \
COPY --from=build /app/.output ./.output COPY --from=build /app/.output ./.output
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
COPY server/cli/migrate-to-single-images.mjs ./server/cli/migrate-to-single-images.mjs
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh
+10 -34
View File
@@ -32,7 +32,7 @@
| Branch | Notes | | Branch | Notes |
| ------ | ----- | | ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.2.0** (in progress) — unified `images/` storage, multi-backend, dual-domain isolation, [`/storage`](docs/README.md), [`migration guide`](docs/migration-to-v1.2.en.md) | | [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.4** — local disk + multi-backend storage, bidirectional dual-domain isolation, [`/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 | | [**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. Use **main** for the full product and multiple cloud backends.
@@ -59,14 +59,14 @@ git checkout cloudflare
## Features ## Features
- **Drag, click, or Ctrl+V paste** to upload; all files under `images/` - **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 - **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL
- **Multi-user** — username/password login; optional registration; users see only their images - **Multi-user** — username/password login; optional registration; users see only their images
- **Upload preferences** (home card flip): 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`) - **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) - **Hybrid URLs**: `proxy` (same-origin via PicHost) or `public` (302 to CDN / bucket URL)
- **Gallery** — browse, search, batch delete; admin sees uploader badges - **Gallery** — browse, search, batch delete; admin sees uploader badges
- **Stats** — upload/delete metrics, upload source breakdown; admin sees registered user count - **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count
- **API** — global token (admin) + per-user tokens; in-app cURL snippets - **API** — global token (admin) + per-user tokens; in-app cURL snippets
- **Twikoo** — `POST /api/index.php` (`image` + `token`) - **Twikoo** — `POST /api/index.php` (`image` + `token`)
- **Zero-config Docker** — first-run web wizard, no secrets required upfront - **Zero-config Docker** — first-run web wizard, no secrets required upfront
@@ -95,27 +95,6 @@ docker exec pichost reset-password <username> # admin or regular user
A random password is printed. Fails if the user does not exist. A random password is printed. Fails if the user does not exist.
Local: `npm run reset-password` or `npm run reset-password -- <username>`
### Upgrading to v1.2.0 (legacy local directories)
From v1.2.0, all images live under `data/images/`. Migration is two steps:
1. **CLI** (manual): move images from every top-level folder under `data/` except `images` into `data/images/{original-dir}/...`
2. **Startup sync** (automatic): scan disk, fill SQLite index, normalize legacy keys, remove orphan rows
```bash
docker exec pichost migrate # preview + data/mapping.json
docker exec pichost migrate --apply # move files
# upgrade image and restart — index sync logs appear on startup
```
Local: `npm run migrate` / `npm run migrate -- --apply`.
Full guide (layout, logs, URLs, **other image hosts**): [`docs/migration-to-v1.2.en.md`](docs/migration-to-v1.2.en.md)
Skip the CLI if you only use object storage or files are already under `data/images/`.
If you cloned the repo, you can also use `docker compose up -d` (see `docker-compose.yml`). If you cloned the repo, you can also use `docker compose up -d` (see `docker-compose.yml`).
### Local development ### Local development
@@ -151,19 +130,19 @@ Open `http://localhost:3000/setup` to create the admin account. Data defaults to
| Gallery list / search / delete | Session or token | Users: own only; admin: all | | Gallery list / search / delete | Session or token | Users: own only; admin: all |
| Direct link `GET /images/...` | None (Referer rules) | Public if URL is known | | Direct link `GET /images/...` | None (Referer rules) | Public if URL is known |
All uploads are stored under `images/`. Regular users are limited to the `images/` folder; custom `folder` requires admin or global token.
## Storage layout ## Storage layout
``` ```
/data /data
├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index ├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
└── images/ # All images (local backend); may include migrated subpaths ├── images/ # Default upload folder (when using local backend)
├── blog/ # Example custom folder
└── twikoo/ # Twikoo comment images
``` ```
New uploads: `images/randomId.webp` or `images/YYYY/MM/randomId.webp`. Migrated legacy paths may look like `images/blog/...`. Metadata lives in SQLite `images`. With cloud backends, blobs live in the bucket. Back up `data/images/`, `pichost.db`, and bucket data. 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.
Upgrading from v1.1.x with parallel folders such as `data/blog/`: see [Upgrading to v1.2.0](#upgrading-to-v120-legacy-local-directories) or [`docs/migration-to-v1.2.en.md`](docs/migration-to-v1.2.en.md).
## API overview ## API overview
@@ -179,6 +158,7 @@ Upload example:
```bash ```bash
curl -X POST "https://pic.example.com/api/images/upload" \ curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \ -H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png" -F "image=@./demo.png"
``` ```
@@ -242,17 +222,13 @@ npm run start # Run .output
npm run lint # ESLint npm run lint # ESLint
npm run typecheck # Type check npm run typecheck # Type check
npm test # Unit tests npm test # Unit tests
npm run reset-password # Reset password (optional username)
npm run migrate # Preview legacy dir migration (-- --apply to run)
``` ```
## Roadmap ## Roadmap
| Version | Plan | | Version | Plan |
| ---------- | -------------------------------------------------------------- | | ---------- | -------------------------------------------------------------- |
| **v1.2.0** | Unified `images/` storage, CLI migration, startup index sync, upload source stats (in progress) | | **v1.1.4** | Flat vs date storage paths, basename public URLs, bidirectional domain isolation (current `main`) |
| **v1.1.5** | Gallery preview reliability: auto-retry, storage verify, no-cache errors |
| **v1.1.4** | Flat vs date storage paths, basename public URLs, bidirectional domain isolation |
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check | | **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check |
| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits | | **v1.1.2** | Activity logs, gallery storage filter, upload rate limits |
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging | | **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
+10 -36
View File
@@ -32,7 +32,7 @@
| 分支 | 说明 | | 分支 | 说明 |
| ---- | ---- | | ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.2.0**(开发中)— 统一 `images/` 存储、多后端、双域名隔离、[`/storage`](docs/README.md)、[`迁移指南`](docs/migration-to-v1.2.md) | | [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.4** 主线:本地磁盘 + 多对象存储、双域名双向隔离、[`/storage`](docs/README.md) 存储管理、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 | | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
日常使用、需要管理多种存储后端,请直接用 **main**。 日常使用、需要管理多种存储后端,请直接用 **main**。
@@ -59,14 +59,14 @@ git checkout cloudflare
## 特性 ## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;图片统一存入 `images/` - **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名 - **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片 - **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除 - **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除
- **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端 - **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端
- **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址) - **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址)
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签 - **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
- **统计**:上传/删除趋势、上传来源分布;管理员额外显示注册用户数量 - **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL - **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
- **Twikoo**:`POST /api/index.php`(`image` + `token`) - **Twikoo**:`POST /api/index.php`(`image` + `token`)
- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥 - **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥
@@ -100,29 +100,6 @@ docker exec pichost reset-password 用户名
本地:`npm run reset-password` 或 `npm run reset-password -- 用户名` 本地:`npm run reset-password` 或 `npm run reset-password -- 用户名`
### 升级到 v1.2.0(本地磁盘遗留目录)
v1.2.0 起图片统一在 `data/images/`。迁移分两步:
1. **CLI 搬文件**(手动,升级前后均可):把 `data/` 下除 `images` 外各顶层目录里的图片迁入 `data/images/{原目录名}/...`
2. **启动时同步索引**(自动):扫描 `data/images/` 补全 SQLite、归一化遗留 key、清理无文件的孤儿记录
```bash
# 1. 预览迁移并生成 data/mapping.json(旧 key → 新 key)
docker exec pichost migrate
# 2. 确认后执行文件移动
docker exec pichost migrate --apply
# 3. 升级镜像并重启;启动日志会打印索引同步过程
```
本地:`npm run migrate` / `npm run migrate -- --apply`。
详细说明(结构对比、日志含义、外链替换、**其它图床迁入**):[`docs/migration-to-v1.2.md`](docs/migration-to-v1.2.md)
仅对象存储、或图片已在 `data/images/` 下时,可跳过 CLI,仅依赖启动同步。
已克隆仓库时也可用 `docker compose up -d`(见仓库内 `docker-compose.yml`)。 已克隆仓库时也可用 `docker compose up -d`(见仓库内 `docker-compose.yml`)。
### 本地开发 ### 本地开发
@@ -158,19 +135,19 @@ npm run dev
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 | | 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 | | 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
所有上传统一存入 `images/` 目录。 普通用户上传目录固定为 `images/`;自定义 `folder` 仅管理员或全局 Token 可用。
## 存储结构 ## 存储结构
``` ```
/data /data
├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引 ├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
└── images/ # 全部图片(本地后端时);可含迁移子路径如 file/、年/月/日/ ├── images/ # 默认上传目录(本地后端时)
├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片
``` ```
路径规则:新上传为 `images/随机ID.webp` 或 `images/年/月/随机ID.webp`;迁移遗留可为更深子路径(如 `images/blog/...`)。元数据在 SQLite `images` 表;云存储时文件在对应桶内。备份请包含 `data/images/`、`pichost.db` 与云桶数据。 路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。云存储时文件在对应桶内,SQLite `images` 表记录 `backend_id` 与 `key`。备份请包含整个 `/data` 与云桶数据。
从 v1.1.x 升级且存在 `data/blog/` 等并列目录时,见 [升级到 v1.2.0](#升级到-v120本地磁盘遗留目录) 或 [`docs/migration-to-v1.2.md`](docs/migration-to-v1.2.md)。
## API 概览 ## API 概览
@@ -186,6 +163,7 @@ npm run dev
```bash ```bash
curl -X POST "https://pic.example.com/api/images/upload" \ curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \ -H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png" -F "image=@./demo.png"
``` ```
@@ -249,17 +227,13 @@ npm run start # 运行 .output
npm run lint # ESLint npm run lint # ESLint
npm run typecheck # 类型检查 npm run typecheck # 类型检查
npm test # 单元测试 npm test # 单元测试
npm run reset-password # 重置密码(可选用户名参数)
npm run migrate # 预览遗留目录迁移(加 -- --apply 执行)
``` ```
## 路线图 ## 路线图
| 版本 | 说明 | | 版本 | 说明 |
| ---------- | ------------------------------------------------- | | ---------- | ------------------------------------------------- |
| **v1.2.0** | 统一 `images/` 存储、遗留目录 CLI 迁移、启动索引同步、上传来源统计(开发中) | | **v1.1.4** | 存储路径扁平/分组、basename 外链、双域名双向隔离(当前 main) |
| **v1.1.5** | 图库预览可靠性:自动重试、出图前校验、错误响应禁缓存 |
| **v1.1.4** | 存储路径扁平/分组、basename 外链、双域名双向隔离 |
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示 | | **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示 |
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 | | **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 | | **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
-53
View File
@@ -1,53 +0,0 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
code: string
scrollable?: boolean
}>(),
{
scrollable: true
}
)
const toast = useToast()
const { t } = useI18n()
async function copyCode() {
try {
await navigator.clipboard.writeText(props.code)
toast.add({ title: t('copy.curl'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
}
}
</script>
<template>
<div class="overflow-hidden rounded-xl bg-neutral-950">
<div
class="flex items-center justify-between border-b border-neutral-800/80 bg-neutral-900 px-3.5 py-2.5"
>
<div
class="flex items-center gap-2"
aria-hidden="true"
>
<span class="size-3 rounded-full border border-black/15 bg-[#ff5f56]" />
<span class="size-3 rounded-full border border-black/15 bg-[#ffbd2e]" />
<span class="size-3 rounded-full border border-black/15 bg-[#27c93f]" />
</div>
<UButton
icon="i-lucide-copy"
:label="t('common.copy')"
size="xs"
variant="ghost"
color="neutral"
class="text-neutral-300 hover:text-white"
@click="copyCode"
/>
</div>
<pre
class="overflow-auto p-4 text-xs leading-relaxed text-neutral-100"
:class="props.scrollable ? 'max-h-80' : ''"
><code class="font-mono">{{ props.code }}</code></pre>
</div>
</template>
+6 -50
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ImageItem } from '~/types/image' import type { ImageItem } from '~/types/image'
import type { CopyFormat } from '~/composables/useUploadPreferences'
type CopyFormat = 'url' | 'markdown' | 'html'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
image: ImageItem image: ImageItem
@@ -24,52 +25,8 @@ const emit = defineEmits<{
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const { copyFormat } = useUploadPreferences()
const imageError = ref(false) const imageError = ref(false)
const retryCount = ref(0) const copyFormat = ref<CopyFormat>('url')
function selectCopyFormat(value: CopyFormat) {
copyFormat.value = value
}
const MAX_PREVIEW_RETRIES = 3
const PREVIEW_RETRY_DELAYS_MS = [1000, 2000, 4000]
const previewSrc = computed(() => {
if (retryCount.value === 0) return props.image.url
const separator = props.image.url.includes('?') ? '&' : '?'
return `${props.image.url}${separator}retry=${retryCount.value}`
})
watch(() => props.image.key, () => {
imageError.value = false
retryCount.value = 0
})
let retryTimer: ReturnType<typeof setTimeout> | undefined
function clearRetryTimer() {
if (retryTimer !== undefined) {
clearTimeout(retryTimer)
retryTimer = undefined
}
}
function onPreviewError() {
if (retryCount.value >= MAX_PREVIEW_RETRIES) {
imageError.value = true
return
}
const delay = PREVIEW_RETRY_DELAYS_MS[retryCount.value] ?? PREVIEW_RETRY_DELAYS_MS.at(-1)!
clearRetryTimer()
retryTimer = setTimeout(() => {
retryCount.value += 1
retryTimer = undefined
}, delay)
}
onUnmounted(clearRetryTimer)
const copyFormatItems = computed(() => [ const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const }, { label: t('copy.url'), value: 'url' as const },
@@ -121,12 +78,11 @@ function toggleSelected(value: boolean | 'indeterminate') {
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted"> <div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
<img <img
v-if="!imageError" v-if="!imageError"
:key="`${image.key}-${retryCount}`" :src="image.url"
:src="previewSrc"
:alt="image.originalName" :alt="image.originalName"
loading="lazy" loading="lazy"
class="absolute inset-0 size-full object-cover object-center" class="absolute inset-0 size-full object-cover object-center"
@error="onPreviewError" @error="imageError = true"
> >
<div <div
v-else v-else
@@ -234,7 +190,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
:variant="copyFormat === item.value ? 'solid' : 'ghost'" :variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'" :color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label" :label="item.label"
@click="selectCopyFormat(item.value)" @click="() => { copyFormat = item.value }"
/> />
</div> </div>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
export interface DonutSegment {
label: string
value: number
color: string
}
const props = defineProps<{
segments: DonutSegment[]
size?: number
}>()
const total = computed(() =>
props.segments.reduce((sum, item) => sum + item.value, 0)
)
const gradient = computed(() => {
if (!total.value) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
let offset = 0
const stops = props.segments
.filter(item => item.value > 0)
.map((item) => {
const start = offset
const percent = (item.value / total.value) * 100
offset += percent
return `${item.color} ${start}% ${offset}%`
})
if (!stops.length) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
return `conic-gradient(${stops.join(', ')})`
})
function percent(value: number) {
if (!total.value) return 0
return Math.round((value / total.value) * 100)
}
function formatCount(value: number) {
if (value >= 100000000) {
return `${(value / 100000000).toFixed(value >= 1000000000 ? 0 : 1)}亿`
}
if (value >= 10000) {
return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
}
return String(value)
}
const centerText = computed(() => formatCount(total.value))
const centerTextClass = computed(() => {
const raw = String(total.value)
if (raw.length >= 7) return 'text-[9px] font-semibold leading-none'
if (raw.length >= 5) return 'text-xs font-semibold leading-none'
if (raw.length >= 4) return 'text-sm font-semibold leading-none'
return 'text-lg font-semibold leading-none'
})
</script>
<template>
<div class="flex items-center gap-4">
<div
class="relative shrink-0 rounded-full"
:style="{
width: `${size ?? 88}px`,
height: `${size ?? 88}px`,
background: gradient
}"
>
<div
class="absolute inset-[20%] flex flex-col items-center justify-center rounded-full bg-elevated px-1 text-center"
>
<p
class="max-w-full truncate tabular-nums"
:class="centerTextClass"
:title="String(total)"
>
{{ centerText }}
</p>
<p class="mt-0.5 text-[10px] text-muted">
次
</p>
</div>
</div>
<ul class="min-w-0 flex-1 space-y-1.5">
<li
v-for="item in segments"
:key="item.label"
class="flex items-center gap-2 text-xs"
>
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
<span
class="shrink-0 tabular-nums text-muted"
:title="`${item.value}`"
>
{{ formatCount(item.value) }} ({{ percent(item.value) }}%)
</span>
</li>
</ul>
</div>
</template>
+58 -20
View File
@@ -12,13 +12,20 @@ export function useImageList() {
const totalPages = ref(1) const totalPages = ref(1)
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const totalCount = ref<number | null>(null) const folderTotal = ref<number | null>(null)
const loadingTotal = ref(false) const loadingTotal = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const activeSearch = ref('') const activeSearch = ref('')
const activeFolder = ref('all')
const activeStorageBackend = ref('all') const activeStorageBackend = ref('all')
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([]) const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
function folderQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return {}
return { folder }
}
function storageQueryParams(): Record<string, string> { function storageQueryParams(): Record<string, string> {
const backendId = activeStorageBackend.value.trim() const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return {} if (!backendId || backendId === 'all') return {}
@@ -26,7 +33,16 @@ export function useImageList() {
} }
function listQueryParams(): Record<string, string> { function listQueryParams(): Record<string, string> {
return storageQueryParams() return {
...folderQueryParams(),
...storageQueryParams()
}
}
function matchesActiveFolder(key: string) {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`)
} }
function matchesActiveStorage(item: ImageItem) { function matchesActiveStorage(item: ImageItem) {
@@ -58,9 +74,9 @@ export function useImageList() {
credentials: 'include', credentials: 'include',
query: listQueryParams() query: listQueryParams()
}) })
totalCount.value = data.total folderTotal.value = data.total
} catch { } catch {
totalCount.value = null folderTotal.value = null
} finally { } finally {
loadingTotal.value = false loadingTotal.value = false
} }
@@ -122,13 +138,27 @@ export function useImageList() {
await Promise.all([fetchList(1), fetchTotal()]) await Promise.all([fetchList(1), fetchTotal()])
} }
async function initializeList() { async function initializeList(folder = 'all') {
const next = folder.trim() || 'all'
activeFolder.value = next
page.value = 1 page.value = 1
activeSearch.value = '' activeSearch.value = ''
searchQuery.value = '' searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()]) await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()])
} }
async function setActiveFolder(folder: string) {
const next = folder.trim() || 'all'
if (next === activeFolder.value) return
activeFolder.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function setActiveStorageBackend(backendId: string) { async function setActiveStorageBackend(backendId: string) {
const next = backendId.trim() || 'all' const next = backendId.trim() || 'all'
if (next === activeStorageBackend.value) return if (next === activeStorageBackend.value) return
@@ -162,12 +192,14 @@ export function useImageList() {
} }
function prependItems(newItems: ImageItem[]) { function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => matchesActiveStorage(item)) const matched = newItems.filter(item =>
matchesActiveFolder(item.key) && matchesActiveStorage(item)
)
if (!matched.length || page.value !== 1) return if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value] items.value = [...matched, ...items.value]
total.value += matched.length total.value += matched.length
if (totalCount.value !== null) { if (folderTotal.value !== null) {
totalCount.value += matched.length folderTotal.value += matched.length
} }
} }
@@ -178,8 +210,8 @@ export function useImageList() {
if (removed > 0) { if (removed > 0) {
total.value = Math.max(0, total.value - removed) total.value = Math.max(0, total.value - removed)
} }
if (totalCount.value !== null && removed > 0) { if (folderTotal.value !== null && removed > 0) {
totalCount.value = Math.max(0, totalCount.value - removed) folderTotal.value = Math.max(0, folderTotal.value - removed)
} }
} }
@@ -191,30 +223,34 @@ export function useImageList() {
}) })
const listSummary = computed(() => { const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
const storageLabel = activeStorageLabel.value const storageLabel = activeStorageLabel.value
if (loadingTotal.value && totalCount.value === null) { if (loadingTotal.value && folderTotal.value === null) {
return storageLabel return storageLabel
? `${storageLabel} · ${t('stats.counting')}` ? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}`
: t('stats.counting') : `${folderLabel} · ${t('stats.counting')}`
} }
if (activeSearch.value) { if (activeSearch.value) {
if (storageLabel) { if (storageLabel) {
return t('stats.summarySearchStorage', { return t('stats.summarySearchStorage', {
folder: folderLabel,
storage: storageLabel,
q: activeSearch.value, q: activeSearch.value,
total: total.value, total: total.value
storage: storageLabel
}) })
} }
return t('stats.summarySearch', { q: activeSearch.value, total: total.value }) return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value })
} }
const count = totalCount.value === null ? total.value : totalCount.value const count = folderTotal.value === null ? total.value : folderTotal.value
if (storageLabel) { if (storageLabel) {
return t('stats.summaryTotalStorage', { return t('stats.summaryTotalStorage', {
total: count, folder: folderLabel,
storage: storageLabel storage: storageLabel,
total: count
}) })
} }
return t('stats.summaryTotal', { total: count }) return t('stats.summaryTotal', { folder: folderLabel, total: count })
}) })
return { return {
@@ -223,6 +259,7 @@ export function useImageList() {
totalPages, totalPages,
total, total,
loading, loading,
activeFolder,
activeStorageBackend, activeStorageBackend,
storageBackendOptions, storageBackendOptions,
searchQuery, searchQuery,
@@ -233,6 +270,7 @@ export function useImageList() {
fetchTotal, fetchTotal,
refreshList, refreshList,
initializeList, initializeList,
setActiveFolder,
setActiveStorageBackend, setActiveStorageBackend,
loadStorageBackendOptions, loadStorageBackendOptions,
submitSearch, submitSearch,
+5 -1
View File
@@ -17,7 +17,8 @@ export function useImageUpload() {
async function uploadFiles( async function uploadFiles(
files: File[], files: File[],
options?: { notify?: boolean } folder = 'images',
options?: { notify?: boolean, includeFolder?: boolean }
) { ) {
if (uploading.value) return if (uploading.value) return
@@ -54,6 +55,9 @@ export function useImageUpload() {
})) }))
const formData = new FormData() const formData = new FormData()
if (options?.includeFolder !== false) {
formData.append('folder', folder.trim() || 'images')
}
for (const file of prepared) { for (const file of prepared) {
formData.append('files', file, file.name) formData.append('files', file, file.name)
} }
+10 -1
View File
@@ -3,6 +3,7 @@ const STORAGE_KEY = 'pichost.upload-preferences'
export type CopyFormat = 'url' | 'markdown' | 'html' export type CopyFormat = 'url' | 'markdown' | 'html'
export interface UploadPreferences { export interface UploadPreferences {
defaultFolder: string
compressEnabled: boolean compressEnabled: boolean
autoCopyMarkdown: boolean autoCopyMarkdown: boolean
copyFormat: CopyFormat copyFormat: CopyFormat
@@ -10,6 +11,7 @@ export interface UploadPreferences {
} }
const DEFAULTS: UploadPreferences = { const DEFAULTS: UploadPreferences = {
defaultFolder: 'images',
compressEnabled: false, compressEnabled: false,
autoCopyMarkdown: false, autoCopyMarkdown: false,
copyFormat: 'markdown', copyFormat: 'markdown',
@@ -34,6 +36,9 @@ function readStored(): UploadPreferences {
if (!raw) return { ...DEFAULTS } if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<UploadPreferences> const parsed = JSON.parse(raw) as Partial<UploadPreferences>
return { return {
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
? parsed.defaultFolder.trim()
: DEFAULTS.defaultFolder,
compressEnabled: parsed.compressEnabled === true, compressEnabled: parsed.compressEnabled === true,
autoCopyMarkdown: parsed.autoCopyMarkdown === true, autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat, copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
@@ -50,6 +55,7 @@ function writeStored(prefs: UploadPreferences) {
} }
export function useUploadPreferences() { export function useUploadPreferences() {
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled) const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown) const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat) const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
@@ -58,6 +64,7 @@ export function useUploadPreferences() {
function loadPreferences() { function loadPreferences() {
const stored = readStored() const stored = readStored()
defaultFolder.value = stored.defaultFolder
compressEnabled.value = stored.compressEnabled compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat copyFormat.value = stored.copyFormat
@@ -67,6 +74,7 @@ export function useUploadPreferences() {
function savePreferences() { function savePreferences() {
writeStored({ writeStored({
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
compressEnabled: compressEnabled.value, compressEnabled: compressEnabled.value,
autoCopyMarkdown: autoCopyMarkdown.value, autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value, copyFormat: copyFormat.value,
@@ -74,13 +82,14 @@ export function useUploadPreferences() {
}) })
} }
watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => { watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
if (loaded.value) { if (loaded.value) {
savePreferences() savePreferences()
} }
}) })
return { return {
defaultFolder,
compressEnabled, compressEnabled,
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
+59 -12
View File
@@ -52,13 +52,34 @@ const authHeader = computed(() =>
const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`) const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
const apiDocs = computed<ApiDocItem[]>(() => { const apiDocs = computed<ApiDocItem[]>(() => {
const uploadDescription = t('api.docs.upload.desc') const uploadDescription = isAdmin.value
const uploadCurl = `curl -X POST "${baseUrl.value}/api/images/upload" \\ ? t('api.docs.upload.descAdmin')
: t('api.docs.upload.descUser')
const uploadCurl = isAdmin.value
? `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "image=@./demo.png"
${t('api.docs.upload.curlCustomFolder')}
curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "folder=blog" \\
-F "image=@./demo.png"`
: `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\ ${authHeaderFlag.value} \\
-F "image=@./demo.png"` -F "image=@./demo.png"`
const listDescription = t('api.docs.list.desc') const listDescription = isAdmin.value
const listCurl = `curl "${baseUrl.value}/api/images?limit=20&page=1" \\ ? t('api.docs.list.descAdmin')
: t('api.docs.list.descUser')
const listCurl = isAdmin.value
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}
${t('api.docs.list.curlFolderFilter')}
curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
${authHeaderFlag.value}`
: `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}` ${authHeaderFlag.value}`
return [ return [
@@ -135,12 +156,10 @@ function methodBadgeColor(method: ApiDocItem['method']) {
switch (method) { switch (method) {
case 'GET': case 'GET':
return 'info' as const return 'info' as const
case 'POST':
return 'warning' as const
case 'DELETE': case 'DELETE':
return 'error' as const return 'error' as const
default: default:
return 'neutral' as const return 'primary' as const
} }
} }
@@ -200,6 +219,15 @@ async function confirmRegenerate() {
} }
} }
async function copyCurl(text: string) {
try {
await navigator.clipboard.writeText(text)
toast.add({ title: t('copy.curl'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
}
}
function closeConfirmRegenerate() { function closeConfirmRegenerate() {
confirmRegenerateOpen.value = false confirmRegenerateOpen.value = false
} }
@@ -376,7 +404,18 @@ watch(isAuthenticated, async (authed, prev) => {
<p class="mb-2 text-xs font-medium text-muted"> <p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }} {{ t('api.curlExample') }}
</p> </p>
<ApiCurlBlock :code="doc.curl" /> <div class="relative overflow-hidden rounded-xl bg-neutral-950">
<UButton
icon="i-lucide-copy"
:label="t('common.copy')"
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(doc.curl)"
/>
<pre class="max-h-80 overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ doc.curl }}</code></pre>
</div>
</div> </div>
</article> </article>
</div> </div>
@@ -392,7 +431,7 @@ watch(isAuthenticated, async (authed, prev) => {
<div class="min-w-0 space-y-2"> <div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<UBadge <UBadge
:color="methodBadgeColor(twikooDoc.method)" color="neutral"
variant="subtle" variant="subtle"
size="sm" size="sm"
> >
@@ -415,10 +454,18 @@ watch(isAuthenticated, async (authed, prev) => {
<p class="mb-2 text-xs font-medium text-muted"> <p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }} {{ t('api.curlExample') }}
</p> </p>
<ApiCurlBlock <div class="relative overflow-hidden rounded-xl bg-neutral-950">
:code="twikooDoc.curl" <UButton
:scrollable="false" icon="i-lucide-copy"
:label="t('common.copy')"
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(twikooDoc.curl)"
/> />
<pre class="overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ twikooDoc.curl }}</code></pre>
</div>
</div> </div>
</div> </div>
</section> </section>
+29 -2
View File
@@ -4,14 +4,17 @@ import type { ImageItem } from '~/types/image'
const { uploading, progressItems, uploadFiles } = useImageUpload() const { uploading, progressItems, uploadFiles } = useImageUpload()
const { const {
defaultFolder,
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
loadPreferences loadPreferences
} = useUploadPreferences() } = useUploadPreferences()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth() const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin }
= useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const folderOptions = ref<string[]>(['images'])
const sessionItems = ref<ImageItem[]>([]) const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set()) const deletingKeys = ref<Set<string>>(new Set())
const emptySelection = ref(new Set<string>()) const emptySelection = ref(new Set<string>())
@@ -27,9 +30,22 @@ const showProgress = computed(
) )
) )
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
if (!folderOptions.value.includes(defaultFolder.value)) {
defaultFolder.value = folderOptions.value[0] ?? 'images'
}
}
async function loadPage() { async function loadPage() {
try { try {
loadPreferences() loadPreferences()
if (isAdmin.value) {
await loadFolders()
}
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
} }
@@ -96,9 +112,20 @@ async function handleUpload(files: File[]) {
} }
try { try {
const result = await uploadFiles(files) const uploadTarget = isAdmin.value
? (defaultFolder.value.trim() || 'images')
: 'images'
const result = await uploadFiles(files, uploadTarget, {
includeFolder: isAdmin.value
})
if (result?.items.length) { if (result?.items.length) {
sessionItems.value = [...result.items, ...sessionItems.value] sessionItems.value = [...result.items, ...sessionItems.value]
if (isAdmin.value) {
const folder = uploadTarget
if (!folderOptions.value.includes(folder)) {
folderOptions.value = [...folderOptions.value, folder]
}
}
if (autoCopyMarkdown.value && result.items[0]) { if (autoCopyMarkdown.value && result.items[0]) {
const first = result.items[0] const first = result.items[0]
const text const text
+4 -1
View File
@@ -38,7 +38,8 @@ const actionItems = computed(() => [
const sourceItems = computed(() => [ const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const }, { label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const }, { label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceApi'), value: 'api' as const } { label: t('logs.sourceApi'), value: 'api' as const },
{ label: t('logs.sourceTwikoo'), value: 'twikoo' as const }
]) ])
const userItems = computed(() => { const userItems = computed(() => {
@@ -92,6 +93,8 @@ function sourceLabel(source: LogSource) {
return t('logs.sourceWeb') return t('logs.sourceWeb')
case 'api': case 'api':
return t('logs.sourceApi') return t('logs.sourceApi')
case 'twikoo':
return t('logs.sourceTwikoo')
} }
} }
+132 -31
View File
@@ -27,10 +27,12 @@ interface SettingsResponse {
} }
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth() const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const { defaultFolder, loadPreferences } = useUploadPreferences()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const settings = ref<SettingsResponse | null>(null) const settings = ref<SettingsResponse | null>(null)
const folderOptions = ref<string[]>(['images'])
const savingServer = ref(false) const savingServer = ref(false)
const refererDraft = ref('') const refererDraft = ref('')
@@ -40,6 +42,7 @@ const hideFolderInUrlDraft = ref(false)
const storageUseDatePathDraft = ref(true) const storageUseDatePathDraft = ref(true)
const allowRegistrationDraft = ref(false) const allowRegistrationDraft = ref(false)
const domainSeparationDraft = ref(false) const domainSeparationDraft = ref(false)
const hideFolderConfirmOpen = ref(false)
interface ReleaseCheckResponse { interface ReleaseCheckResponse {
currentVersion: string currentVersion: string
@@ -50,6 +53,10 @@ interface ReleaseCheckResponse {
const releaseCheck = ref<ReleaseCheckResponse | null>(null) const releaseCheck = ref<ReleaseCheckResponse | null>(null)
const folderSelectItems = computed(() =>
folderOptions.value.map(folder => ({ label: folder, value: folder }))
)
const hasServerChanges = computed(() => { const hasServerChanges = computed(() => {
if (!settings.value) return false if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts return refererDraft.value !== settings.value.allowedRefererHosts
@@ -124,6 +131,28 @@ function handlePatchError(error: unknown, fallback: string) {
toast.add({ title: fallback, color: 'error' }) toast.add({ title: fallback, color: 'error' })
} }
function onHideFolderDraftChange(next: boolean | 'indeterminate') {
if (next !== true) {
hideFolderInUrlDraft.value = false
return
}
if (folderOptions.value.length > 1) {
hideFolderConfirmOpen.value = true
return
}
hideFolderInUrlDraft.value = true
}
function confirmHideFolderEnable() {
hideFolderInUrlDraft.value = true
hideFolderConfirmOpen.value = false
}
function cancelHideFolderEnable() {
hideFolderInUrlDraft.value = false
hideFolderConfirmOpen.value = false
}
function onDomainSeparationDraftChange(next: boolean | 'indeterminate') { function onDomainSeparationDraftChange(next: boolean | 'indeterminate') {
const enabled = next === true const enabled = next === true
domainSeparationDraft.value = enabled domainSeparationDraft.value = enabled
@@ -157,6 +186,16 @@ async function saveServerSettings() {
} }
} }
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
if (!folderOptions.value.includes(defaultFolder.value)) {
defaultFolder.value = folderOptions.value[0] ?? 'images'
}
}
async function loadSettings() { async function loadSettings() {
try { try {
const data = await $fetch<SettingsResponse>('/api/settings', { const data = await $fetch<SettingsResponse>('/api/settings', {
@@ -186,7 +225,8 @@ async function loadPage() {
await navigateTo('/') await navigateTo('/')
return return
} }
await Promise.all([loadSettings(), checkLatestRelease()]) loadPreferences()
await Promise.all([loadSettings(), loadFolders(), checkLatestRelease()])
} }
onMounted(async () => { onMounted(async () => {
@@ -314,7 +354,7 @@ watch(isAuthenticated, async (authed, prev) => {
:model-value="hideFolderInUrlDraft" :model-value="hideFolderInUrlDraft"
class="mt-0.5 shrink-0" class="mt-0.5 shrink-0"
:disabled="settings.hideFolderInUrlSource === 'env'" :disabled="settings.hideFolderInUrlSource === 'env'"
@update:model-value="(v) => hideFolderInUrlDraft = v === true" @update:model-value="onHideFolderDraftChange"
/> />
<span class="min-w-0"> <span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium"> <span class="flex flex-wrap items-center gap-2 text-sm font-medium">
@@ -335,11 +375,50 @@ watch(isAuthenticated, async (authed, prev) => {
</label> </label>
</div> </div>
<div class="flex h-full min-w-0 flex-col space-y-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5"> <div class="settings-side-panel flex h-full min-w-0 flex-col rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<div class="shrink-0 space-y-1.5">
<label class="text-sm font-medium">{{ t('settings.defaultFolder') }}</label>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.defaultFolderHint') }}
</p>
<USelect
v-model="defaultFolder"
:items="folderSelectItems"
class="settings-folder-select w-40 max-w-full"
/>
</div>
<div class="settings-referer-section mt-4 space-y-1.5 border-t border-default/60 pt-4">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.accessControl') }}</label>
<UBadge
:color="sourceBadge(settings.refererSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.refererSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.refererHint') }}
</p>
<UTextarea
v-model="refererDraft"
:placeholder="t('settings.refererPlaceholder')"
:rows="4"
:autoresize="false"
class="settings-referer-field flex min-h-0 w-full max-w-xl flex-1 flex-col font-mono text-sm"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
{{ t('settings.domainSettings') }} {{ t('settings.domainSettings') }}
</h3> </h3>
<div class="mt-4 space-y-4">
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3"> <label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox <UCheckbox
:model-value="domainSeparationDraft" :model-value="domainSeparationDraft"
@@ -390,7 +469,7 @@ watch(isAuthenticated, async (authed, prev) => {
<UInput <UInput
v-model="siteBaseUrlDraft" v-model="siteBaseUrlDraft"
:placeholder="t('settings.siteBaseUrlPlaceholder')" :placeholder="t('settings.siteBaseUrlPlaceholder')"
class="w-full font-mono text-sm" class="w-full max-w-xl font-mono text-sm"
/> />
<p <p
v-if="settings.siteBaseUrl" v-if="settings.siteBaseUrl"
@@ -418,7 +497,7 @@ watch(isAuthenticated, async (authed, prev) => {
<UInput <UInput
v-model="imageBaseUrlDraft" v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')" :placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm" class="w-full max-w-xl font-mono text-sm"
/> />
<p <p
v-if="settings.imageBaseUrl" v-if="settings.imageBaseUrl"
@@ -450,7 +529,7 @@ watch(isAuthenticated, async (authed, prev) => {
<UInput <UInput
v-model="imageBaseUrlDraft" v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')" :placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm" class="w-full max-w-xl font-mono text-sm"
/> />
<p <p
v-if="settings.imageBaseUrl" v-if="settings.imageBaseUrl"
@@ -462,31 +541,6 @@ watch(isAuthenticated, async (authed, prev) => {
</div> </div>
</div> </div>
</div> </div>
<div class="settings-referer-section space-y-1.5 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
{{ t('settings.accessControl') }}
</h3>
<UBadge
:color="sourceBadge(settings.refererSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.refererSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.refererHint') }}
</p>
<UTextarea
v-model="refererDraft"
:placeholder="t('settings.refererPlaceholder')"
:rows="4"
:autoresize="false"
class="settings-referer-field w-full font-mono text-sm"
/>
</div>
</div> </div>
</div> </div>
@@ -564,11 +618,57 @@ watch(isAuthenticated, async (authed, prev) => {
</div> </div>
</section> </section>
</AppShell> </AppShell>
<UModal
:open="hideFolderConfirmOpen"
:title="t('settings.hideFolderConfirmTitle')"
:description="t('settings.hideFolderConfirmDesc')"
@update:open="(open) => { if (!open) cancelHideFolderEnable() }"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="cancelHideFolderEnable"
/>
<UButton
:label="t('settings.hideFolderConfirmEnable')"
color="warning"
@click="confirmHideFolderEnable"
/>
</div>
</template>
</UModal>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.settings-side-panel {
overflow: hidden;
}
.settings-folder-select {
min-width: 0;
}
.settings-folder-select :deep(button),
.settings-folder-select :deep([role='combobox']) {
justify-content: space-between;
}
.settings-referer-section { .settings-referer-section {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.settings-referer-field {
flex: 1;
min-height: 0;
min-width: 0; min-width: 0;
} }
@@ -580,6 +680,7 @@ watch(isAuthenticated, async (authed, prev) => {
} }
.settings-referer-field :deep(textarea) { .settings-referer-field :deep(textarea) {
height: 100%;
min-height: 5.5rem; min-height: 5.5rem;
resize: none; resize: none;
} }
+115 -47
View File
@@ -12,7 +12,17 @@ interface StatsResponse {
bySource: { bySource: {
web: number web: number
api: number api: number
twikoo: number
} }
byFolder: Array<{
folder: string
count: number
bytes: number
}>
byFolderUploads: Array<{
folder: string
count: number
}>
} }
const { const {
@@ -28,6 +38,7 @@ const {
fetchList, fetchList,
fetchSearch, fetchSearch,
fetchTotal, fetchTotal,
setActiveFolder,
submitSearch, submitSearch,
goToPage, goToPage,
removeItems, removeItems,
@@ -52,7 +63,14 @@ const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([]) const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false) const batchDeleting = ref(false)
const showScrollTop = ref(false) const showScrollTop = ref(false)
const currentFolder = ref('all')
const currentStorage = ref('all') const currentStorage = ref('all')
const folderOptions = ref<string[]>(['images'])
const folderSelectItems = computed(() => [
{ label: t('stats.allFolders'), value: 'all' },
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
])
const storageSelectItems = computed(() => [ const storageSelectItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' }, { label: t('stats.filterStorageAll'), value: 'all' },
@@ -64,29 +82,41 @@ const storageSelectItems = computed(() => [
const selectedCount = computed(() => selectedKeys.value.size) const selectedCount = computed(() => selectedKeys.value.size)
const SOURCE_CHART_COLORS = [ const FOLDER_CHART_COLORS = [
'var(--ui-primary)', 'var(--ui-primary)',
'#10b981' '#10b981',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4'
] ]
const sourceUploadItems = computed(() => { const folderUploadItems = computed(() =>
const bySource = stats.value?.bySource (stats.value?.byFolderUploads ?? []).map((item, index) => ({
if (!bySource) return [] key: item.folder,
return [ label: item.folder,
{ key: 'web', label: t('stats.sourceWeb'), count: bySource.web, color: SOURCE_CHART_COLORS[0]! }, count: item.count,
{ key: 'api', label: t('stats.sourceApi'), count: bySource.api, color: SOURCE_CHART_COLORS[1]! } color: FOLDER_CHART_COLORS[index % FOLDER_CHART_COLORS.length]!
] }))
})
const sourceUploadTotal = computed(() =>
sourceUploadItems.value.reduce((sum, item) => sum + item.count, 0)
) )
function sourcePercent(count: number) { const donutSegments = computed(() =>
const total = sourceUploadTotal.value folderUploadItems.value.map(item => ({
if (!total) return 0 label: item.label,
return Math.round((count / total) * 100) value: item.count,
} color: item.color
}))
)
const folderStats = computed(() => stats.value?.byFolder ?? [])
const folderMaxBytes = computed(() =>
Math.max(...folderStats.value.map(item => item.bytes), 1)
)
const uploadChartTotal = computed(() =>
folderUploadItems.value.reduce((sum, item) => sum + item.count, 0)
)
const statCards = computed(() => { const statCards = computed(() => {
const s = stats.value const s = stats.value
@@ -113,6 +143,17 @@ function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
function folderPercent(bytes: number) {
return Math.round((bytes / folderMaxBytes.value) * 100)
}
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
}
async function fetchStats() { async function fetchStats() {
stats.value = await $fetch<StatsResponse>('/api/stats', { stats.value = await $fetch<StatsResponse>('/api/stats', {
credentials: 'include' credentials: 'include'
@@ -137,7 +178,7 @@ async function refreshGallery() {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedKeys.value = new Set() selectedKeys.value = new Set()
try { try {
await Promise.all([refreshList(), loadStorageBackendOptions()]) await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()])
currentStorage.value = activeStorageBackend.value currentStorage.value = activeStorageBackend.value
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
@@ -161,6 +202,7 @@ async function refreshAll() {
await Promise.all([ await Promise.all([
fetchStats(), fetchStats(),
reloadGallery(), reloadGallery(),
loadFolders(),
loadStorageBackendOptions() loadStorageBackendOptions()
]) ])
currentStorage.value = activeStorageBackend.value currentStorage.value = activeStorageBackend.value
@@ -174,6 +216,17 @@ async function refreshAll() {
} }
} }
async function handleFolderChange(folder: string) {
if (!isAuthenticated.value) return
currentFolder.value = folder
selectedKeys.value = new Set()
try {
await setActiveFolder(folder)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleStorageChange(backendId: string) { async function handleStorageChange(backendId: string) {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
currentStorage.value = backendId currentStorage.value = backendId
@@ -353,8 +406,7 @@ watch(isAuthenticated, async (authed, prev) => {
/> />
</div> </div>
<section class="grid gap-3 lg:grid-cols-3 lg:items-stretch"> <section class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="grid grid-cols-2 gap-3 lg:col-span-2 lg:grid-cols-4">
<div <div
v-for="item in statCards" v-for="item in statCards"
:key="item.label" :key="item.label"
@@ -367,46 +419,54 @@ watch(isAuthenticated, async (authed, prev) => {
{{ item.value }} {{ item.value }}
</p> </p>
</div> </div>
</div> </section>
<div class="flex h-full min-h-0 flex-col rounded-xl border border-default bg-elevated p-4 lg:col-span-1"> <section class="grid gap-4 lg:grid-cols-2">
<h2 class="shrink-0 text-sm font-medium text-muted"> <div class="rounded-xl border border-default bg-elevated p-4">
{{ t('stats.sourceDistribution') }} <h2 class="mb-3 text-sm font-medium text-muted">
{{ t('stats.folderDistribution') }}
</h2> </h2>
<div <div
v-if="!sourceUploadTotal" v-if="!uploadChartTotal"
class="flex flex-1 items-center justify-center text-center text-xs text-muted" class="py-6 text-center text-xs text-muted"
>
{{ t('stats.noData') }}
</div>
<StatsDonut
v-else
:segments="donutSegments"
:size="88"
/>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted">
{{ t('stats.folderUsage') }}
</h2>
<div
v-if="!folderStats.length"
class="py-6 text-center text-xs text-muted"
> >
{{ t('stats.noData') }} {{ t('stats.noData') }}
</div> </div>
<ul <ul
v-else v-else
class="mt-4 flex min-h-0 flex-1 flex-col" class="space-y-2.5"
> >
<li <li
v-for="item in sourceUploadItems" v-for="item in folderStats"
:key="item.key" :key="item.folder"
class="flex flex-1 flex-col justify-center gap-2 py-1"
> >
<div class="flex items-center justify-between gap-2 text-xs"> <div class="mb-1 flex items-center justify-between gap-2 text-xs">
<span class="flex min-w-0 items-center gap-1.5"> <span class="font-medium">{{ item.folder }}</span>
<span <span class="tabular-nums text-muted">
class="size-2 shrink-0 rounded-full" {{ t('stats.folderCount', { count: item.count, size: formatFileSize(item.bytes) }) }}
:style="{ background: item.color }"
/>
<span class="truncate">{{ item.label }}</span>
</span>
<span class="shrink-0 tabular-nums text-muted">
{{ item.count }} ({{ sourcePercent(item.count) }}%)
</span> </span>
</div> </div>
<div class="h-2 rounded-full bg-muted/40"> <div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div <div
class="h-full rounded-full transition-[width]" class="h-full rounded-full bg-primary/70"
:style="{ :style="{ width: `${Math.max(folderPercent(item.bytes), item.bytes ? 4 : 0)}%` }"
width: `${sourcePercent(item.count)}%`,
backgroundColor: item.color
}"
/> />
</div> </div>
</li> </li>
@@ -440,6 +500,14 @@ watch(isAuthenticated, async (authed, prev) => {
> >
{{ t('stats.selected', { n: selectedCount }) }} {{ t('stats.selected', { n: selectedCount }) }}
</UBadge> </UBadge>
<USelect
v-model="currentFolder"
:items="folderSelectItems"
class="w-[5.5rem] shrink-0 sm:w-32"
size="sm"
:aria-label="t('stats.folder')"
@update:model-value="handleFolderChange"
/>
<USelect <USelect
v-model="currentStorage" v-model="currentStorage"
:items="storageSelectItems" :items="storageSelectItems"
+15 -8
View File
@@ -1,10 +1,15 @@
export type LogAction = 'upload' | 'delete' export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' export type LogSource = 'web' | 'api' | 'twikoo'
export interface ActivityLogUser {
id: number
username: string
}
export interface ActivityLogStorage { export interface ActivityLogStorage {
id: string id: string
name: string name: string
type: string type: 'local' | 's3'
} }
export interface ActivityLogItem { export interface ActivityLogItem {
@@ -21,16 +26,18 @@ export interface ActivityLogItem {
createdAt: string createdAt: string
} }
export interface ActivityLogSummary {
total: number
upload: number
delete: number
}
export interface ActivityLogListResponse { export interface ActivityLogListResponse {
items: ActivityLogItem[] items: ActivityLogItem[]
total: number total: number
page: number page: number
pageSize: number pageSize: number
totalPages: number totalPages: number
summary: { summary: ActivityLogSummary
total: number users?: ActivityLogUser[]
upload: number
delete: number
}
users?: Array<{ id: number, username: string }>
} }
-1
View File
@@ -10,7 +10,6 @@ services:
- "6892:6892" - "6892:6892"
# 首次访问 Web 引导创建管理员,无需预先配置环境变量 # 首次访问 Web 引导创建管理员,无需预先配置环境变量
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等 # 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
# 运维:docker exec pichost reset-password | migrate [--apply]
volumes: volumes:
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data) # 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
- ./data:/data - ./data:/data
+1 -5
View File
@@ -2,11 +2,7 @@
set -e set -e
if [ "$1" = "reset-password" ]; then if [ "$1" = "reset-password" ]; then
exec node /app/server/cli/reset-password.mjs "${@:2}" exec node /app/server/cli/reset-password.mjs
fi
if [ "$1" = "migrate" ]; then
exec node /app/server/cli/migrate-to-single-images.mjs "${@:2}"
fi fi
exec node /app/.output/server/index.mjs "$@" exec node /app/.output/server/index.mjs "$@"
+3 -5
View File
@@ -6,7 +6,7 @@
| 分支 | 说明 | | 分支 | 说明 |
| ---- | ---- | | ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.2.0**(开发中)主线:统一 `images/` 存储、多对象存储、[`/storage`](docs/README.md)、[`迁移指南`](migration-to-v1.2.md) | | [**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 | | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署场景,配置更聚焦 R2 |
- 需要多种云存储或完整管理界面 → 使用 **main** - 需要多种云存储或完整管理界面 → 使用 **main**
@@ -14,7 +14,7 @@
## 截图一览 ## 截图一览
以下为 **main** 界面截图。 以下为 **main(v1.1.0)** 界面截图。
| 文件 | 说明 | | 文件 | 说明 |
| ---- | ---- | | ---- | ---- |
@@ -31,12 +31,10 @@
| 文档 | 说明 | | 文档 | 说明 |
| ---- | ---- | | ---- | ---- |
| [migration-to-v1.2.md](migration-to-v1.2.md) | v1.2.0 本地磁盘迁移(CLI 搬文件 + 启动同步索引) |
| [migration-to-v1.2.en.md](migration-to-v1.2.en.md) | v1.2.0 migration guide (English) |
| [domain-separation.md](domain-separation.md) | 双域名分离推荐架构(Nginx / Lucky / 环境变量) | | [domain-separation.md](domain-separation.md) | 双域名分离推荐架构(Nginx / Lucky / 环境变量) |
| [domain-separation.en.md](domain-separation.en.md) | Dual-domain deployment (English) | | [domain-separation.en.md](domain-separation.en.md) | Dual-domain deployment (English) |
## 相关页面路由(main) ## 相关页面路由(main / v1.1.0)
| 路由 | 权限 | 说明 | | 路由 | 权限 | 说明 |
| ---- | ---- | ---- | | ---- | ---- | ---- |
-129
View File
@@ -1,129 +0,0 @@
# v1.2.0 local disk migration guide
From v1.2.0, **all local image files** live under `data/images/`; SQLite `keys` also start with `images/`. Legacy top-level folders next to `images` (e.g. `data/blog/`, `data/twikoo/`) are no longer used.
Migration is **two steps**: run the **CLI** to move files on disk, then let **startup sync** align the SQLite index.
---
## Migrating from other image hosts (EasyImage, Lsky, Chevereto, …)
There is **no** dedicated “pull from URLs” or “import via vendor API” tool. Moving from **legacy PicHost** or **another self-hosted** install uses the same **local file** workflow:
1. Locate the **image files** on the old server (copy the tree, not just the database).
2. Place them under PicHost `data/` as a **top-level folder** next to `images` (any name — it becomes `images/{name}/...`).
3. Run `migrate` / `migrate --apply`, then restart PicHost.
| Source | Typical path on old server | After copy into PicHost |
| ------ | -------------------------- | ----------------------- |
| PicHost v1.1.x | `data/blog/`, `data/twikoo/` | Already under `data/` → run `migrate` |
| EasyImage(s) | `i/` or `upload/` | e.g. `data/easyimage/` → `migrate` |
| Lsky | `public/uploads/` etc. | e.g. `data/lsky/` → `migrate` |
| Chevereto etc. | `images/` or `content/` | e.g. `data/chevereto/` → `migrate` |
| Custom | your folder name | `data/your-folder/` → `migrate` |
The CLI scans **every top-level directory under `data/` except `images`**, not only `blog` / `twikoo`.
**Limits:** files only — accounts, tokens, and old public URLs are not imported. `mapping.json` maps old **storage keys**, not full old-site URLs. For S3/R2-only legacy hosts, sync blobs to `data/…` or attach the bucket in PicHost **Storage** settings.
---
## Layout before and after
### Before (typical v1.1.x)
```
/data
├── pichost.db
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
├── twikoo/
└── images/
```
Index keys might look like `blog/2026/08/TeKJ2fD6cB6j.webp`.
### After (v1.2.0)
```
/data
├── pichost.db
├── mapping.json
└── images/
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
└── twikoo/
```
Index key: `images/blog/2026/08/TeKJ2fD6cB6j.webp`.
---
## Step 1: CLI file migration
Run **before or after** upgrading the image; backup `data/` first.
```bash
docker exec pichost migrate # preview + mapping.json
docker exec pichost migrate --apply # move files
```
Local: `npm run migrate` / `npm run migrate -- --apply`.
| Item | Behavior |
| ---- | -------- |
| Scan | Every top-level directory under `data/` **except `images`** |
| Target | `data/images/{original-top-dir}/{relative-path}` |
| Extensions | jpg, jpeg, png, webp, gif, svg, ico |
| Sidecar | `.meta.json` next to an image is moved if present |
| Other files | Not moved; parent directory kept if anything remains |
| Mapping | `data/mapping.json` — old key → new key |
Script: `server/cli/migrate-to-single-images.mjs`.
---
## Step 2: Startup index sync (automatic)
Runs on every start (`npm run dev`, Docker). No manual command.
```text
[INFO] 开始同步图片索引:扫描本地磁盘 ...
[INFO] 磁盘扫描完成:...
[INFO] 开始归一化遗留图片路径 ...
[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录
[INFO] 图片索引同步结束
```
| Step | Purpose |
| ---- | ------- |
| Scan `data/images/` | Insert missing index rows for files on disk |
| Normalize legacy keys | `blog/...` → `images/blog/...` when files are in place |
| Purge orphans | Remove index rows with no file on disk |
| Repair Content-Type | Fix `application/octet-stream` entries |
The CLI only **moves files**. Index alignment runs at startup — **restart** after `migrate --apply`.
---
## Recommended upgrade flow
1. Backup `data/`.
2. `docker exec pichost migrate` — review output and `mapping.json`.
3. `docker exec pichost migrate --apply`.
4. Deploy v1.2.0 and restart.
5. Check logs for successful index sync.
6. Use `mapping.json` to update external URLs if needed.
Skip step 1 if you only use object storage or everything is already under `data/images/`.
---
## URLs
| Old key | `blog/TeKJ2fD6cB6j.webp` |
| New key | `images/blog/TeKJ2fD6cB6j.webp` |
With “hide folder” mode, short URLs like `/TeKJ2fD6cB6j.webp` may still resolve by filename. Use `mapping.json` for bulk replacements.
Chinese version: [migration-to-v1.2.md](migration-to-v1.2.md)
-178
View File
@@ -1,178 +0,0 @@
# v1.2.0 本地磁盘迁移指南
v1.2.0 起,**所有本地图片文件**统一放在 `data/images/` 下;SQLite `images` 表中的 `key` 也以 `images/` 开头。旧版在 `data/blog/`、`data/twikoo/` 等与 `images` **并列的顶层目录**不再符合新模型。
迁移分 **两步**:先用手动 CLI **搬文件**,再由服务 **启动时自动同步索引**。
---
## 从其它图床迁入(EasyImage / 兰空 / Chevereto 等)
**没有**单独的「一键从外链拉取」或「对接某某图床 API」工具。从 **PicHost 旧版** 或其它自托管图床迁入,走的是同一套 **本地文件迁移**:
1. 在旧图床服务器上找到图片**物理目录**(整目录拷贝,不要只拷数据库)。
2. 把该目录放到 PicHost 的 `data/` 下,作为与 `images` **并列的顶层文件夹**(文件夹名任意,会保留为 `images/{文件夹名}/...`)。
3. 执行 `migrate` / `migrate --apply`,再重启 PicHost。
### 常见来源示例
| 来源 | 典型图片目录(在旧服务器上) | 放入 PicHost 后示例 |
| ---- | ---------------------------- | ------------------- |
| PicHost v1.1.x | `data/blog/`、`data/twikoo/` | 已在 `data/` 下,直接 `migrate` |
| EasyImage / EasyImages | `i/` 或安装目录下的 `upload/` | 拷到 `data/easyimage/` 再 `migrate` |
| 兰空图床 (Lsky) | `public/uploads/` 等 | 拷到 `data/lsky/` 再 `migrate` |
| Chevereto 等 | `images/` 或 `content/` | 拷到 `data/chevereto/` 再 `migrate` |
| 任意自定义目录 | 你的顶层文件夹名 | 拷到 `data/你的目录名/` 再 `migrate` |
脚本会扫描 `data/` 下**除 `images` 外所有顶层目录**里的图片,不限于 `blog` / `twikoo`。子目录结构会原样保留在 `images/{顶层名}/` 下。
### 操作示例(Docker)
```bash
# 在宿主机:把从旧图床打包的目录放进挂载卷(名称自定)
# 例如:./data/easyimage/2026/01/photo.jpg
docker exec pichost migrate # 预览
docker exec pichost migrate --apply
# 重启容器 → 启动日志会补全 SQLite 索引
```
### 说明与限制
- **只迁移文件**:旧图床的账号、Token、外链 URL **不会**自动导入;`mapping.json` 里的 key 是「旧磁盘相对 key → 新 key」,**不是**旧站完整 URL,替换外链需自行对照。
- **对象存储**:若旧图床图片在 S3/R2 桶里,请用桶工具同步到本地 `data/某目录/`,或直接在 PicHost **存储**页配置同一桶(不经过本 CLI)。
- **已直接在 `data/images/` 下**:无需 `migrate`,重启后启动同步会自动建索引。
---
## 存储结构对比
### 迁移前(v1.1.x 常见)
```
/data
├── pichost.db
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
├── twikoo/
│ └── xxx.webp
└── images/ # 可能已有部分新图
└── ...
```
SQLite 中 `key` 可能是 `blog/2026/08/TeKJ2fD6cB6j.webp`(与磁盘顶层目录一致)。
### 迁移后(v1.2.0)
```
/data
├── pichost.db
├── mapping.json # CLI 生成:旧 key → 新 key(供替换外链)
└── images/
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
├── twikoo/
│ └── xxx.webp
└── ... # 新上传:images/随机ID.webp 或 images/年/月/随机ID.webp
```
对应 `key`:`images/blog/2026/08/TeKJ2fD6cB6j.webp`。
---
## 第一步:CLI 迁移磁盘文件
在 **升级镜像之前或之后**均可执行;建议在升级前完成,并先备份整个 `data/` 目录。
### Docker
```bash
# 预览:列出待迁移文件,写入 data/mapping.json(旧 key → 新 key)
docker exec pichost migrate
# 确认无误后执行文件移动
docker exec pichost migrate --apply
```
### 本地开发
```bash
npm run migrate # 预览
npm run migrate -- --apply # 执行
```
### 脚本行为
| 项目 | 说明 |
| ---- | ---- |
| 扫描范围 | `data/` 下**除 `images` 外所有顶层目录**(不限于 blog/twikoo) |
| 迁移目标 | `data/images/{原顶层目录名}/{相对路径}` |
| 图片类型 | `.jpg` `.jpeg` `.png` `.webp` `.gif` `.svg` `.ico` |
| 伴随文件 | 若存在同名的 `.meta.json` 会一并移动 |
| 非图片文件 | 不移动;若目录内仍有其它文件,**保留原目录**不删除 |
| 映射表 | `data/mapping.json`,例如 `"blog/foo.webp": "images/blog/foo.webp"` |
脚本路径:`server/cli/migrate-to-single-images.mjs`。
---
## 第二步:启动时自动同步索引
每次 PicHost 启动(`npm run dev`、Docker 容器启动)会自动执行,**无需手动命令**。日志示例:
```text
[INFO] 开始同步图片索引:扫描本地磁盘 {"path":".../data/images/"}
[INFO] 磁盘扫描完成:已补全缺失索引 {"inserted":3}
[INFO] 开始归一化遗留图片路径(如 blog/ → images/blog/)
[INFO] 遗留路径归一化完成:无需调整
[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录
[INFO] 孤儿索引清理完成:未发现孤儿记录
[INFO] 开始修复存量 Content-Type 元数据
[INFO] Content-Type 修复完成:无需修复
[INFO] 图片索引同步结束
```
| 步骤 | 作用 |
| ---- | ---- |
| 扫描 `data/images/` | 磁盘上有、索引里没有 → **补全** `images` 表 |
| 归一化遗留 key | 将 `blog/...` 等旧 key 更新为 `images/blog/...`(文件已在正确路径时) |
| 清理孤儿索引 | 索引里有记录、磁盘无文件 → **删除**幽灵记录(避免图库出现删不掉的项) |
| 修复 Content-Type | 修正错误的 `application/octet-stream` |
CLI 只负责**搬文件**;索引对齐、遗留 key、孤儿记录由启动同步处理。执行 `migrate --apply` 后**重启一次**服务即可。
---
## 推荐升级流程
1. **备份** `data/`(含 `pichost.db` 与各目录)。
2. `docker exec pichost migrate` 预览,检查输出与 `mapping.json`。
3. `docker exec pichost migrate --apply` 执行迁移。
4. 拉取 / 部署 **v1.2.0** 镜像并重启容器。
5. 查看启动日志,确认「图片索引同步结束」无报错。
6. 用 `mapping.json` 批量替换博客、Twikoo 等外链中的旧路径(若需要)。
仅使用对象存储、或图片早已全部在 `data/images/` 下时,**跳过第一步**;第二步仍会在启动时运行,通常几秒内完成且无变更日志。
---
## 外链与直链
| 场景 | 说明 |
| ---- | ---- |
| 旧 key | `blog/TeKJ2fD6cB6j.webp` |
| 新 key | `images/blog/TeKJ2fD6cB6j.webp` |
| 隐藏目录模式 | 若开启「隐藏 images 前缀」,短链可能为 `/TeKJ2fD6cB6j.webp`;服务按文件名反查索引 |
| 替换建议 | 以 `mapping.json` 为准;或重新在图库复制新链接 |
---
## 相关命令
| 命令 | 说明 |
| ---- | ---- |
| `docker exec pichost migrate` | 预览磁盘迁移 |
| `docker exec pichost migrate --apply` | 执行磁盘迁移 |
| `docker exec pichost reset-password` | 重置密码(与迁移无关) |
英文版:[migration-to-v1.2.en.md](migration-to-v1.2.en.md)
+28 -13
View File
@@ -88,7 +88,7 @@
"domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)", "domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
"domainSeparationProxyExample": "View example", "domainSeparationProxyExample": "View example",
"siteBaseUrl": "Site URL", "siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint", "siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com", "siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "Image URL", "imageBaseUrl": "Image URL",
"imageBaseUrlHint": "No trailing slash; public image link prefix", "imageBaseUrlHint": "No trailing slash; public image link prefix",
@@ -179,16 +179,21 @@
"settings": { "settings": {
"title": "System settings", "title": "System settings",
"subtitle": "Saved to database; environment variables take precedence", "subtitle": "Saved to database; environment variables take precedence",
"uploadAccount": "Upload & accounts",
"featureToggles": "Feature toggles", "featureToggles": "Feature toggles",
"defaultFolder": "Default upload folder",
"defaultFolderHint": "Stored in this browser only; affects your upload target",
"allowRegistration": "Open registration", "allowRegistration": "Open registration",
"allowRegistrationHint": "Allow new users to register (regular users see only their images)", "allowRegistrationHint": "Allow new users to register (regular users see only their images)",
"accessControl": "Access control", "accessControl": "Access control",
"refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction", "refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction",
"refererPlaceholder": "example.com, www.example.com", "refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "Domains & links",
"domainSettings": "Domain settings", "domainSettings": "Domain settings",
"storagePathSettings": "Storage paths & direct links",
"domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API", "domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
"siteBaseUrl": "Site URL", "siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint", "siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com", "siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "Active: {url}", "siteBaseUrlActive": "Active: {url}",
"imageBaseUrl": "Image URL", "imageBaseUrl": "Image URL",
@@ -197,7 +202,10 @@
"imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "Active: {url}", "imageBaseUrlActive": "Active: {url}",
"hideFolderInUrl": "Hide folder in image URLs", "hideFolderInUrl": "Hide folder in image URLs",
"hideFolderInUrlHint": "Links become /file.webp without folder or date paths; legacy full paths still work. Identical filenames under different subpaths may collide on short URLs", "hideFolderInUrlHint": "Links become /file.webp without folder or date paths; legacy full paths still work",
"hideFolderConfirmTitle": "Hide folder prefix in URLs?",
"hideFolderConfirmDesc": "If two folders share the same filename (e.g. images/a.webp and blog/a.webp), the short URL /a.webp resolves to the images folder first—you may get the wrong file. Enable only with a single upload folder or globally unique filenames.",
"hideFolderConfirmEnable": "Enable anyway",
"storageUseDatePath": "Group uploads by year/month", "storageUseDatePath": "Group uploads by year/month",
"storageUseDatePathHint": "When enabled, files are stored as images/2026/08/file.webp; when disabled, as images/file.webp. Applies only to new uploads after enabling", "storageUseDatePathHint": "When enabled, files are stored as images/2026/08/file.webp; when disabled, as images/file.webp. Applies only to new uploads after enabling",
"badgeEnv": ".env", "badgeEnv": ".env",
@@ -309,11 +317,12 @@
"deleteTotal": "Total deleted", "deleteTotal": "Total deleted",
"userCount": "Registered users", "userCount": "Registered users",
"uploadBytes": "Total upload size", "uploadBytes": "Total upload size",
"sourceDistribution": "Uploads by source", "folderDistribution": "Uploads by folder",
"sourceWeb": "Web", "folderUsage": "Storage by folder",
"sourceApi": "API",
"noData": "No data", "noData": "No data",
"folderCount": "{count} · {size}",
"gallery": "Gallery", "gallery": "Gallery",
"folder": "Folder",
"filterStorageAll": "All storage", "filterStorageAll": "All storage",
"storage": "Storage", "storage": "Storage",
"searchPlaceholder": "Search filename", "searchPlaceholder": "Search filename",
@@ -327,11 +336,12 @@
"deletePartialFail": "{n} image(s) failed to delete", "deletePartialFail": "{n} image(s) failed to delete",
"deleteFailed": "Delete failed", "deleteFailed": "Delete failed",
"deletedSingle": "Deleted", "deletedSingle": "Deleted",
"allFolders": "All",
"counting": "Counting…", "counting": "Counting…",
"summarySearch": "Search “{q}” · {total} images", "summarySearch": "{folder} · Search “{q}” · {total} images",
"summaryTotal": "{total} images", "summaryTotal": "{folder} · {total} images",
"summarySearchStorage": "{storage} · Search “{q}” · {total} images", "summarySearchStorage": "{folder} · {storage} · Search “{q}” · {total} images",
"summaryTotalStorage": "{storage} · {total} images" "summaryTotalStorage": "{folder} · {storage} · {total} images"
}, },
"logs": { "logs": {
"pageTitle": "Activity log", "pageTitle": "Activity log",
@@ -349,6 +359,7 @@
"actionDelete": "Delete", "actionDelete": "Delete",
"sourceWeb": "Web", "sourceWeb": "Web",
"sourceApi": "API", "sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "Time", "colTime": "Time",
"colUser": "User", "colUser": "User",
"colStorage": "Storage", "colStorage": "Storage",
@@ -407,11 +418,15 @@
"docs": { "docs": {
"upload": { "upload": {
"title": "Upload images", "title": "Upload images",
"desc": "Upload one or more images via image field. Also accepts file, files. Stored under images/." "descAdmin": "Upload one or more images. Use image field; folder creates directory if missing. Also accepts file, files, type.",
"descUser": "Upload one or more images via image field. Also accepts file, files. Stored under images/.",
"curlCustomFolder": "# Upload to custom folder blog/"
}, },
"list": { "list": {
"title": "List images", "title": "List images",
"desc": "Paginated gallery. limit default 20, max 100." "descAdmin": "Paginated gallery; optional folder filter. limit default 20, max 100.",
"descUser": "Paginated list of your images. limit default 20, max 100.",
"curlFolderFilter": "# Filter by folder"
}, },
"search": { "search": {
"title": "Search images", "title": "Search images",
@@ -427,7 +442,7 @@
}, },
"twikoo": { "twikoo": {
"title": "Twikoo-compatible upload", "title": "Twikoo-compatible upload",
"desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token." "desc": "Twikoo / EasyImage 2.0 protocol; stored under twikoo/. Fields: image + token."
} }
} }
}, },
+28 -13
View File
@@ -88,7 +88,7 @@
"domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892", "domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
"domainSeparationProxyExample": "查看示例", "domainSeparationProxyExample": "查看示例",
"siteBaseUrl": "网站域名", "siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址", "siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com", "siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "图片域名", "imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址", "imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
@@ -179,16 +179,21 @@
"settings": { "settings": {
"title": "系统设置", "title": "系统设置",
"subtitle": "保存后写入数据库;环境变量优先级更高", "subtitle": "保存后写入数据库;环境变量优先级更高",
"uploadAccount": "上传与账号",
"featureToggles": "功能开关", "featureToggles": "功能开关",
"defaultFolder": "默认上传目录",
"defaultFolderHint": "保存在本机浏览器,仅影响您的上传目标目录",
"allowRegistration": "开放用户注册", "allowRegistration": "开放用户注册",
"allowRegistrationHint": "允许新用户自行注册(普通用户仅可见自己的图片)", "allowRegistrationHint": "允许新用户自行注册(普通用户仅可见自己的图片)",
"accessControl": "访问控制", "accessControl": "访问控制",
"refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制", "refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制",
"refererPlaceholder": "example.com, www.example.com", "refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "域名与链接",
"domainSettings": "域名配置", "domainSettings": "域名配置",
"storagePathSettings": "存储路径与直链",
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API", "domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
"siteBaseUrl": "网站域名", "siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址", "siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com", "siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "当前生效:{url}", "siteBaseUrlActive": "当前生效:{url}",
"imageBaseUrl": "图片域名", "imageBaseUrl": "图片域名",
@@ -197,7 +202,10 @@
"imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "当前生效:{url}", "imageBaseUrlActive": "当前生效:{url}",
"hideFolderInUrl": "隐藏直链目录前缀", "hideFolderInUrl": "隐藏直链目录前缀",
"hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧完整路径仍可用。子路径下若存在同名文件,短链可能冲突", "hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧链接仍可用",
"hideFolderConfirmTitle": "确认隐藏目录前缀?",
"hideFolderConfirmDesc": "开启后不同目录下若存在相同文件名(如 images/a.webp 与 blog/a.webp),短链接 /a.webp 将优先解析到 images 目录,可能访问到非预期的图片。仅单一上传目录或文件名全局唯一时建议开启。",
"hideFolderConfirmEnable": "仍要开启",
"storageUseDatePath": "上传目录按年/月分组", "storageUseDatePath": "上传目录按年/月分组",
"storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传", "storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传",
"badgeEnv": ".env", "badgeEnv": ".env",
@@ -309,11 +317,12 @@
"deleteTotal": "累计删除", "deleteTotal": "累计删除",
"userCount": "注册用户数量", "userCount": "注册用户数量",
"uploadBytes": "累计上传体积", "uploadBytes": "累计上传体积",
"sourceDistribution": "上传来源分布", "folderDistribution": "上传目录分布",
"sourceWeb": "网页", "folderUsage": "目录占用",
"sourceApi": "API",
"noData": "暂无数据", "noData": "暂无数据",
"folderCount": "{count} 张 · {size}",
"gallery": "图库记录", "gallery": "图库记录",
"folder": "目录",
"filterStorageAll": "全部存储", "filterStorageAll": "全部存储",
"storage": "存储", "storage": "存储",
"searchPlaceholder": "搜索文件名", "searchPlaceholder": "搜索文件名",
@@ -327,11 +336,12 @@
"deletePartialFail": "{n} 张图片删除失败", "deletePartialFail": "{n} 张图片删除失败",
"deleteFailed": "删除失败", "deleteFailed": "删除失败",
"deletedSingle": "已删除", "deletedSingle": "已删除",
"allFolders": "全部",
"counting": "统计中…", "counting": "统计中…",
"summarySearch": "搜索「{q}」· 共 {total} 张", "summarySearch": "{folder} · 搜索「{q}」· 共 {total} 张",
"summaryTotal": "共 {total} 张", "summaryTotal": "{folder} · 共 {total} 张",
"summarySearchStorage": "{storage} · 搜索「{q}」· 共 {total} 张", "summarySearchStorage": "{folder} · {storage} · 搜索「{q}」· 共 {total} 张",
"summaryTotalStorage": "{storage} · 共 {total} 张" "summaryTotalStorage": "{folder} · {storage} · 共 {total} 张"
}, },
"logs": { "logs": {
"pageTitle": "操作日志", "pageTitle": "操作日志",
@@ -349,6 +359,7 @@
"actionDelete": "删除", "actionDelete": "删除",
"sourceWeb": "网页", "sourceWeb": "网页",
"sourceApi": "API", "sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "时间", "colTime": "时间",
"colUser": "用户", "colUser": "用户",
"colStorage": "存储", "colStorage": "存储",
@@ -407,11 +418,15 @@
"docs": { "docs": {
"upload": { "upload": {
"title": "上传图片", "title": "上传图片",
"desc": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片存入 images/ 目录。" "descAdmin": "上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。",
"descUser": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。",
"curlCustomFolder": "# 上传到自定义目录 blog/"
}, },
"list": { "list": {
"title": "获取图片列表", "title": "获取图片列表",
"desc": "分页列出图库,limit 默认 20、最大 100。" "descAdmin": "分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。",
"descUser": "分页列出您的图库,limit 默认 20、最大 100。",
"curlFolderFilter": "# 按目录筛选"
}, },
"search": { "search": {
"title": "搜索图片", "title": "搜索图片",
@@ -427,7 +442,7 @@
}, },
"twikoo": { "twikoo": {
"title": "twikoo 兼容上传", "title": "twikoo 兼容上传",
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。" "desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。"
} }
} }
}, },
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.2.0", "version": "1.1.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pichost", "name": "pichost",
"version": "1.2.0", "version": "1.1.4",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.2.0", "version": "1.1.4",
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme", "homepage": "https://github.com/O96u/PicHost#readme",
"repository": { "repository": {
@@ -33,8 +33,7 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"start": "node .output/server/index.mjs", "start": "node .output/server/index.mjs",
"reset-password": "node server/cli/reset-password.mjs", "reset-password": "node server/cli/reset-password.mjs"
"migrate": "node server/cli/migrate-to-single-images.mjs"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.876.0", "@aws-sdk/client-s3": "^3.876.0",
+6 -42
View File
@@ -3,9 +3,8 @@ import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } fr
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { MAX_DELETE_BATCH } from '../../utils/constants' import { MAX_DELETE_BATCH } from '../../utils/constants'
import { insertActivityLog } from '../../utils/db' import { insertActivityLog } from '../../utils/db'
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index' import { validateImageKey } from '../../utils/image-key'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key' import { deleteImage, headImage } from '../../utils/storage'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
import { logInfo } from '../../utils/logger' import { logInfo } from '../../utils/logger'
interface BatchDeleteBody { interface BatchDeleteBody {
@@ -34,11 +33,7 @@ export default defineEventHandler(async (event) => {
} }
for (const key of keys) { for (const key of keys) {
if ( if (!validateImageKey(key)) {
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
) {
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400) createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400)
} }
} }
@@ -48,38 +43,7 @@ export default defineEventHandler(async (event) => {
for (const key of keys) { for (const key of keys) {
try { try {
const storageKey = await resolveStorageImageKey(key) ?? key const existing = await headImage(key)
const existing = await headImage(storageKey)
const indexed = getImageIndexRow(key) ?? getImageIndexRow(storageKey)
if (!existing && indexed) {
try {
await assertImageOwnership(event, indexed.user_id)
} catch {
failed.push({
key,
error: { code: 'FORBIDDEN', message: '无权操作此图片' }
})
continue
}
deleteImageIndex(key)
if (storageKey !== key) deleteImageIndex(storageKey)
deleted.push(key)
insertActivityLog({
action: 'delete',
key,
originalName: indexed.original_name,
size: indexed.size,
contentType: indexed.content_type,
source: resolveActivitySource(event),
userId: indexed.user_id ?? null,
backendId: indexed.backend_id ?? null
})
logInfo('delete orphan index', { key })
continue
}
if (!existing) { if (!existing) {
failed.push({ failed.push({
@@ -99,7 +63,7 @@ export default defineEventHandler(async (event) => {
continue continue
} }
await deleteImage(storageKey, key) await deleteImage(key)
deleted.push(key) deleted.push(key)
insertActivityLog({ insertActivityLog({
@@ -113,7 +77,7 @@ export default defineEventHandler(async (event) => {
backendId: existing.backendId ?? null backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key: storageKey, size: existing.size }) logInfo('delete success', { key, size: existing.size })
} catch { } catch {
failed.push({ failed.push({
key, key,
+4 -1
View File
@@ -1,4 +1,5 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access' import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query' import { readBackendIdQuery } from '../../utils/image-query'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { countImages } from '../../utils/storage' import { countImages } from '../../utils/storage'
@@ -8,12 +9,14 @@ export default defineEventHandler(async (event) => {
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const query = getQuery(event) const query = getQuery(event)
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
const backendId = readBackendIdQuery(query) const backendId = readBackendIdQuery(query)
if (backendId === null) { if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400) createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
} }
const total = await countImages(userFilter, backendId) const total = await countImages(folder, userFilter, backendId)
return { total } return { total }
}) })
+6 -39
View File
@@ -2,9 +2,8 @@ import type { DeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access' import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { insertActivityLog } from '../../utils/db' import { insertActivityLog } from '../../utils/db'
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index' import { validateImageKey } from '../../utils/image-key'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key' import { deleteImage, headImage } from '../../utils/storage'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
import { logInfo } from '../../utils/logger' import { logInfo } from '../../utils/logger'
interface DeleteBody { interface DeleteBody {
@@ -20,43 +19,11 @@ export default defineEventHandler(async (event) => {
const key = (typeof query.key === 'string' ? query.key : undefined) const key = (typeof query.key === 'string' ? query.key : undefined)
?? body?.key ?? body?.key
if ( if (!key || !validateImageKey(key)) {
!key
|| (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
)
) {
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400) createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
} }
const storageKey = await resolveStorageImageKey(key) ?? key const existing = await headImage(key)
const existing = await headImage(storageKey)
const indexed = getImageIndexRow(key) ?? getImageIndexRow(storageKey)
if (!existing && indexed) {
await assertImageOwnership(event, indexed.user_id)
deleteImageIndex(key)
if (storageKey !== key) deleteImageIndex(storageKey)
insertActivityLog({
action: 'delete',
key,
originalName: indexed.original_name,
size: indexed.size,
contentType: indexed.content_type,
source: resolveActivitySource(event),
userId: indexed.user_id ?? null,
backendId: indexed.backend_id ?? null
})
logInfo('delete orphan index', { key })
const response: DeleteResponse = { success: true }
return response
}
if (!existing) { if (!existing) {
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404) createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
} }
@@ -64,7 +31,7 @@ export default defineEventHandler(async (event) => {
await assertImageOwnership(event, existing.userId) await assertImageOwnership(event, existing.userId)
try { try {
await deleteImage(storageKey, key) await deleteImage(key)
} catch { } catch {
createApiError(event, 'DELETE_FAILED', '删除失败', 500) createApiError(event, 'DELETE_FAILED', '删除失败', 500)
} }
@@ -80,7 +47,7 @@ export default defineEventHandler(async (event) => {
backendId: existing.backendId ?? null backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key: storageKey, size: existing.size }) logInfo('delete success', { key, size: existing.size })
const response: DeleteResponse = { success: true } const response: DeleteResponse = { success: true }
return response return response
+9 -1
View File
@@ -9,9 +9,16 @@ import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends' import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query' import { readBackendIdQuery } from '../../utils/image-query'
import { listImages } from '../../utils/storage' import { listImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
if (!raw || raw === 'all') return undefined
return isValidFolderName(raw) ? raw : undefined
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event) await requireApiOrAdminAuth(event)
@@ -27,13 +34,14 @@ export default defineEventHandler(async (event) => {
? Math.floor(pageRaw) ? Math.floor(pageRaw)
: 1 : 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query) const backendId = readBackendIdQuery(query)
if (backendId === null) { if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400) createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
} }
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, userFilter, backendId }) const listing = await listImages({ limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
+9 -1
View File
@@ -9,9 +9,16 @@ import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends' import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query' import { readBackendIdQuery } from '../../utils/image-query'
import { searchImages } from '../../utils/storage' import { searchImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
if (!raw || raw === 'all') return undefined
return isValidFolderName(raw) ? raw : undefined
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event) await requireApiOrAdminAuth(event)
@@ -36,13 +43,14 @@ export default defineEventHandler(async (event) => {
? Math.floor(pageRaw) ? Math.floor(pageRaw)
: 1 : 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query) const backendId = readBackendIdQuery(query)
if (backendId === null) { if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400) createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
} }
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, userFilter, backendId }) const result = await searchImages({ query: q, limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
+46 -1
View File
@@ -1,6 +1,6 @@
import { readMultipartFormData } from 'h3' import { readMultipartFormData } from 'h3'
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image' import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId } from '../../utils/access' import { requireUploadAuth, verifyApiUploadToken, getUploadUserId, canUseCustomUploadFolder } from '../../utils/access'
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants' import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
import { import {
detectMimeFromSignature, detectMimeFromSignature,
@@ -11,8 +11,22 @@ import {
processSingleImageUpload processSingleImageUpload
} from '../../utils/process-image-upload' } from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import {
DEFAULT_FOLDER,
isValidFolderName,
normalizeFolderName
} from '../../utils/image-key'
import { checkUploadRateLimit } from '../../utils/rate-limit' import { checkUploadRateLimit } from '../../utils/rate-limit'
function readFormText(
formData: { name?: string, data?: Buffer | Uint8Array }[],
name: string
): string {
const part = formData.find(item => item.name === name && item.data?.length)
if (!part?.data) return ''
return new TextDecoder().decode(part.data).trim()
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUploadAuth(event) await requireUploadAuth(event)
@@ -30,6 +44,36 @@ export default defineEventHandler(async (event) => {
const source = verifyApiUploadToken(event) ? 'api' : 'web' const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event) const uploadUserId = await getUploadUserId(event)
const query = getQuery(event)
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
const folderFromQuery = typeof query.folder === 'string'
? query.folder
: typeof query.type === 'string'
? query.type
: ''
const requestedFolder = normalizeFolderName(folderFromForm || folderFromQuery)
const canCustomFolder = await canUseCustomUploadFolder(event)
if (!canCustomFolder && requestedFolder && requestedFolder !== DEFAULT_FOLDER) {
createApiError(
event,
'FORBIDDEN',
'当前账号不支持自定义上传目录',
403
)
}
const folder = canCustomFolder ? requestedFolder : ''
if (folder && !isValidFolderName(folder)) {
createApiError(
event,
'INVALID_REQUEST',
'无效的 folder:仅允许字母数字开头,可含 - _,最长 32 位,且不能是 api/stats 等保留名',
400
)
}
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图) // 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter( const fileParts = formData.filter(
part => (part.name === 'image' || part.name === 'file' || part.name === 'files') part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
@@ -83,6 +127,7 @@ export default defineEventHandler(async (event) => {
bytes, bytes,
filename: part.filename, filename: part.filename,
source, source,
prefix: folder || DEFAULT_FOLDER,
userId: uploadUserId userId: uploadUserId
}) })
+1 -1
View File
@@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
: undefined : undefined
const sourceRaw = typeof query.source === 'string' ? query.source : '' const sourceRaw = typeof query.source === 'string' ? query.source : ''
const source = (sourceRaw === 'web' || sourceRaw === 'api') const source = (sourceRaw === 'web' || sourceRaw === 'api' || sourceRaw === 'twikoo')
? sourceRaw as LogSource ? sourceRaw as LogSource
: undefined : undefined
+1 -1
View File
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
uploadTotal: scoped.storedCount, uploadTotal: scoped.storedCount,
deleteTotal: 0, deleteTotal: 0,
uploadBytesTotal: scoped.uploadBytesTotal, uploadBytesTotal: scoped.uploadBytesTotal,
bySource: { web: scoped.storedCount, api: 0 }, bySource: { web: scoped.storedCount, api: 0, twikoo: 0 },
byFolderUploads: scoped.byFolder.map(item => ({ byFolderUploads: scoped.byFolder.map(item => ({
folder: item.folder, folder: item.folder,
count: item.count count: item.count
-166
View File
@@ -1,166 +0,0 @@
/**
* 将 data/ 下与 images 并列的顶层目录中的图片迁入 data/images/{原目录名}/...
*
* 用法:
* node server/cli/migrate-to-single-images.mjs # dry-run
* node server/cli/migrate-to-single-images.mjs --apply # 执行迁移
*
* Docker:
* docker exec pichost migrate
* docker exec pichost migrate --apply
*
* 输出 data/mapping.json(旧 key → 新 key)供替换外链。执行后重启 PicHost。
*/
import { promises as fs } from 'node:fs'
import { join, relative, dirname } from 'node:path'
const DATA_DIR = process.env.DATA_DIR || join(process.cwd(), 'data')
const IMAGES_DIR = join(DATA_DIR, 'images')
const IMAGE_EXT = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
const SKIP_TOP_LEVEL_DIRS = new Set(['images'])
const apply = process.argv.includes('--apply')
async function exists(path) {
try {
await fs.access(path)
return true
} catch {
return false
}
}
async function collectImageFiles(dir) {
const files = []
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const full = join(dir, entry.name)
if (entry.isDirectory()) {
files.push(...await collectImageFiles(full))
} else if (
entry.isFile()
&& IMAGE_EXT.test(entry.name)
&& !entry.name.endsWith('.meta.json')
) {
files.push(full)
}
}
return files
}
async function dirHasAnyFiles(dir) {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return false
}
for (const entry of entries) {
if (entry.isFile()) return true
if (entry.isDirectory() && await dirHasAnyFiles(join(dir, entry.name))) {
return true
}
}
return false
}
async function main() {
if (!await exists(DATA_DIR)) {
console.error(`数据目录不存在: ${DATA_DIR}`)
process.exit(1)
}
const topEntries = await fs.readdir(DATA_DIR, { withFileTypes: true })
const sourceDirs = topEntries
.filter(entry => entry.isDirectory() && !SKIP_TOP_LEVEL_DIRS.has(entry.name))
.map(entry => ({ name: entry.name, path: join(DATA_DIR, entry.name) }))
if (!sourceDirs.length) {
console.log('未发现需迁移的顶层目录(data/ 下除 images 外无其它目录)')
return
}
const mapping = {}
const plan = []
for (const { name, path } of sourceDirs) {
const files = await collectImageFiles(path)
for (const filePath of files) {
const rel = relative(path, filePath).replace(/\\/g, '/')
const newKey = `images/${name}/${rel}`
const newDisk = join(IMAGES_DIR, name, rel)
const oldKey = `${name}/${rel}`
mapping[oldKey] = newKey
plan.push({ from: filePath, to: newDisk, oldKey, newKey, sourceName: name })
}
}
if (!plan.length) {
console.log('未发现需迁移的图片(已扫描 data/ 下除 images 外的顶层目录)')
return
}
const scanned = sourceDirs.map(dir => dir.name).join(', ')
console.log(`已扫描顶层目录: ${scanned}`)
console.log(`模式: ${apply ? 'APPLY' : 'DRY-RUN'}`)
console.log(`待迁移 ${plan.length} 个文件:`)
for (const item of plan.slice(0, 20)) {
console.log(` ${item.oldKey} → ${item.newKey}`)
}
if (plan.length > 20) {
console.log(` … 另有 ${plan.length - 20} 个`)
}
const mappingPath = join(DATA_DIR, 'mapping.json')
await fs.writeFile(mappingPath, JSON.stringify(mapping, null, 2))
console.log(`已写入 ${mappingPath}`)
if (!apply) {
console.log('未执行文件移动。确认后运行:')
console.log(' docker exec pichost migrate --apply')
console.log(' 或 npm run migrate -- --apply')
return
}
await fs.mkdir(IMAGES_DIR, { recursive: true })
for (const item of plan) {
await fs.mkdir(dirname(item.to), { recursive: true })
try {
await fs.rename(item.from, item.to)
} catch (error) {
if (error?.code === 'EXDEV') {
await fs.copyFile(item.from, item.to)
await fs.unlink(item.from)
} else {
throw error
}
}
const metaFrom = item.from + '.meta.json'
const metaTo = item.to + '.meta.json'
try {
await fs.rename(metaFrom, metaTo)
} catch {
// optional meta
}
}
const sourceNames = [...new Set(plan.map(item => item.sourceName))]
for (const name of sourceNames) {
const path = join(DATA_DIR, name)
if (await dirHasAnyFiles(path)) {
console.log(`保留 ${name}/(仍有未迁移文件)`)
continue
}
await fs.rm(path, { recursive: true, force: true })
console.log(`已移除空目录 ${name}/`)
}
console.log('迁移完成。请重启 PicHost。')
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
-3
View File
@@ -1,10 +1,7 @@
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation' import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
const HOST_ISOLATION_CACHE_CONTROL = 'private, no-cache, max-age=0'
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) { if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
throw createError({ statusCode: 404, statusMessage: 'Not Found' }) throw createError({ statusCode: 404, statusMessage: 'Not Found' })
} }
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
import { clientIp, logError, logInfo, logWarn } from '../utils/logger' import { clientIp, logError, logInfo, logWarn } from '../utils/logger'
/** /**
* 只记录 /api/*,避免 /images 直链刷屏。 * 只记录 /api/*,避免 /images、/twikoo 直链刷屏。
*/ */
export default defineNitroPlugin((nitroApp) => { export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', (event) => { nitroApp.hooks.hook('request', (event) => {
+15 -58
View File
@@ -1,6 +1,6 @@
import { getDataDir } from '../utils/data-dir' import { getDataDir } from '../utils/data-dir'
import { runAutoDeleteCleanup } from '../utils/auto-delete' import { runAutoDeleteCleanup } from '../utils/auto-delete'
import { migrateLocalImagesToIndex, purgeOrphanImageIndexRows, reconcileLegacyImageKeys, repairLocalImageIndexMetadata } from '../utils/image-index' import { migrateLocalImagesToIndex } from '../utils/image-index'
import { ensureDefaultBackends } from '../utils/storage-backends' import { ensureDefaultBackends } from '../utils/storage-backends'
import { logException, logInfo } from '../utils/logger' import { logException, logInfo } from '../utils/logger'
@@ -16,62 +16,19 @@ export default defineNitroPlugin(() => {
}) })
ensureDefaultBackends() ensureDefaultBackends()
void migrateLocalImagesToIndex().then((count) => {
void (async () => { if (count > 0) {
const dataDir = getDataDir() logInfo('migrated local images to index', { count })
try {
logInfo('开始同步图片索引:扫描本地磁盘', { path: `${dataDir}/images/` })
const inserted = await migrateLocalImagesToIndex()
if (inserted > 0) {
logInfo('磁盘扫描完成:已补全缺失索引', { inserted })
} else {
logInfo('磁盘扫描完成:索引与磁盘一致,无需补全')
} }
}).catch((error) => {
logInfo('开始归一化遗留图片路径(如 blog/ → images/blog/)') logException('migrate local images to index failed', error)
const reconciled = await reconcileLegacyImageKeys() })
if (reconciled.updated > 0 || reconciled.removed > 0) {
logInfo('遗留路径归一化完成', reconciled) void runAutoDeleteCleanup().then((result) => {
} else { if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
logInfo('遗留路径归一化完成:无需调整') logInfo('startup auto-delete', { ...result })
} }
}).catch((error) => {
logInfo('开始校验索引与磁盘一致性,清理无文件的孤儿记录') logException('startup auto-delete failed', error)
const purged = await purgeOrphanImageIndexRows() })
if (purged > 0) {
logInfo('孤儿索引清理完成', { removed: purged })
} else {
logInfo('孤儿索引清理完成:未发现孤儿记录')
}
logInfo('开始修复存量 Content-Type 元数据')
const repaired = repairLocalImageIndexMetadata()
if (repaired > 0) {
logInfo('Content-Type 修复完成', { repaired })
} else {
logInfo('Content-Type 修复完成:无需修复')
}
logInfo('图片索引同步结束')
} catch (error) {
logException('图片索引同步失败', error)
}
})()
void (async () => {
try {
logInfo('开始执行启动时自动删除检查')
const result = await runAutoDeleteCleanup()
if (result.skipped) {
logInfo('自动删除检查完成:策略未启用')
} else if (result.deleted > 0 || result.failed > 0) {
logInfo('自动删除检查完成', { ...result })
} else {
logInfo('自动删除检查完成:无到期图片')
}
} catch (error) {
logException('启动时自动删除失败', error)
}
})()
}) })
+3 -2
View File
@@ -56,11 +56,12 @@ export default defineEventHandler(async (event) => {
return easyImageError(415, '无法识别的图片格式') return easyImageError(415, '无法识别的图片格式')
} }
// Twikoo 走 token 上传,记入 activity log source=api // Twikoo 评论图片单独存放在 twikoo/ 目录下
const result = await processSingleImageUpload(event, { const result = await processSingleImageUpload(event, {
bytes, bytes,
filename: imagePart.filename, filename: imagePart.filename,
source: 'api' prefix: 'twikoo',
source: 'twikoo'
}) })
if ('error' in result) { if ('error' in result) {
+18
View File
@@ -236,3 +236,21 @@ export async function getUploadUserId(event: H3Event): Promise<number | null> {
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
return user?.id ?? null return user?.id ?? null
} }
/** 管理员会话 / 全局 API Token 可使用自定义上传目录 */
export async function canUseCustomUploadFolder(event: H3Event): Promise<boolean> {
if (devBypass(event)) {
return true
}
if (verifyGlobalApiUploadToken(event)) {
return true
}
if (resolveUserIdFromApiToken(event) !== null) {
return false
}
const user = await getCurrentUser(event)
return user?.role === 'admin'
}
-15
View File
@@ -1,15 +0,0 @@
import { describe, expect, it } from 'vitest'
import { contentTypeFromKey } from './content-type'
describe('contentTypeFromKey', () => {
it('infers image types from extensions', () => {
expect(contentTypeFromKey('images/a.webp')).toBe('image/webp')
expect(contentTypeFromKey('images/file/111.webp')).toBe('image/webp')
expect(contentTypeFromKey('images/2026/08/28/123.webp')).toBe('image/webp')
expect(contentTypeFromKey('images/photo.JPG')).toBe('image/jpeg')
})
it('falls back to octet-stream for unknown extensions', () => {
expect(contentTypeFromKey('images/file.dat')).toBe('application/octet-stream')
})
})
-20
View File
@@ -1,20 +0,0 @@
export 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'
}
}
+4 -12
View File
@@ -5,7 +5,7 @@ import { getDataDir } from './data-dir'
import { logException } from './logger' import { logException } from './logger'
export type LogAction = 'upload' | 'delete' export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' export type LogSource = 'web' | 'api' | 'twikoo'
export interface ActivityLogRow { export interface ActivityLogRow {
id: number id: number
@@ -53,7 +53,7 @@ export function getDb(): DatabaseSync {
original_name TEXT NOT NULL, original_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0, size INTEGER NOT NULL DEFAULT 0,
content_type TEXT NOT NULL DEFAULT '', content_type TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK(source IN ('web', 'api')), source TEXT NOT NULL CHECK(source IN ('web', 'api', 'twikoo')),
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
@@ -142,10 +142,6 @@ function migrateSchema(database: DatabaseSync): void {
if (!activityLogColumns.some(column => column.name === 'backend_id')) { if (!activityLogColumns.some(column => column.name === 'backend_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT') database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT')
} }
database.exec(`
UPDATE activity_logs SET source = 'api' WHERE source = 'twikoo'
`)
} }
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at' const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
@@ -619,13 +615,9 @@ function countBySource(): Record<LogSource, number> {
GROUP BY source GROUP BY source
`).all() as unknown as Array<{ source: LogSource, total: number }> `).all() as unknown as Array<{ source: LogSource, total: number }>
const result: Record<LogSource, number> = { web: 0, api: 0 } const result: Record<LogSource, number> = { web: 0, api: 0, twikoo: 0 }
for (const row of rows) { for (const row of rows) {
const raw = row.source as string result[row.source] = row.total
const source = raw === 'twikoo' ? 'api' : row.source
if (source === 'web' || source === 'api') {
result[source] += row.total
}
} }
return result return result
} }
+2 -5
View File
@@ -4,15 +4,12 @@ import { validateImageKey } from './image-key'
describe('image index key validation', () => { describe('image index key validation', () => {
it('accepts standard image keys used in index', () => { it('accepts standard image keys used in index', () => {
expect(validateImageKey('images/2026/03/abc.webp')).toBe(true) expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('images/file/111.webp')).toBe(true) expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('images/2026/08/28/123.webp')).toBe(true)
expect(validateImageKey('images/abc.webp')).toBe(true) expect(validateImageKey('images/abc.webp')).toBe(true)
}) })
it('rejects legacy top-level keys and path traversal', () => { it('rejects path traversal keys', () => {
expect(validateImageKey('../secret')).toBe(false) expect(validateImageKey('../secret')).toBe(false)
expect(validateImageKey('images/../../etc/passwd')).toBe(false) expect(validateImageKey('images/../../etc/passwd')).toBe(false)
expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(false)
expect(validateImageKey('blog/abc.webp')).toBe(false)
}) })
}) })
+80 -151
View File
@@ -1,9 +1,8 @@
import { promises as fs } from 'node:fs' import { promises as fs } from 'node:fs'
import { dirname, join } from 'node:path' import { join } from 'node:path'
import { getDb } from './db' import { getDb } from './db'
import { getDataDir } from './data-dir' import { getDataDir } from './data-dir'
import { contentTypeFromKey } from './content-type' import { DEFAULT_FOLDER, isValidFolderName, sortFolderNames, validateImageKey } from './image-key'
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path' import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
import { import {
ensureDefaultBackends, ensureDefaultBackends,
@@ -101,99 +100,6 @@ export function deleteImageIndex(key: string): void {
getDb().prepare('DELETE FROM images WHERE key = ?').run(key) getDb().prepare('DELETE FROM images WHERE key = ?').run(key)
} }
function preferIndexedImageKey(key: string): string {
const canonical = toCanonicalImageKey(key)
if (canonical && getImageIndexRow(canonical)) return canonical
if (validateImageKey(key)) return key
return key
}
async function fileExistsAtKey(key: string): Promise<boolean> {
try {
await fs.access(keyToFilePath(key))
return true
} catch {
return false
}
}
/** 将 SQLite 中遗留顶层目录 key 归一为 images/ 下路径,并同步磁盘文件 */
export async function reconcileLegacyImageKeys(): Promise<{
updated: number
removed: number
}> {
ensureStorageSchema()
const rows = getDb().prepare('SELECT key FROM images').all() as Array<{ key: string }>
let updated = 0
let removed = 0
for (const { key } of rows) {
if (validateImageKey(key)) continue
const canonical = toCanonicalImageKey(key)
if (!canonical) continue
const canonicalRow = getImageIndexRow(canonical)
if (canonicalRow) {
deleteImageIndex(key)
removed++
continue
}
const canonicalExists = await fileExistsAtKey(canonical)
const legacyExists = await fileExistsAtKey(key)
if (canonicalExists) {
getDb().prepare(`
UPDATE images
SET key = ?, folder = ?
WHERE key = ?
`).run(canonical, DEFAULT_FOLDER, key)
updated++
continue
}
if (legacyExists) {
const legacyPath = keyToFilePath(key)
const canonicalPath = keyToFilePath(canonical)
await fs.mkdir(dirname(canonicalPath), { recursive: true })
await fs.rename(legacyPath, canonicalPath)
try {
await fs.rename(legacyPath + META_SUFFIX, canonicalPath + META_SUFFIX)
} catch {
// no meta
}
getDb().prepare(`
UPDATE images
SET key = ?, folder = ?
WHERE key = ?
`).run(canonical, DEFAULT_FOLDER, key)
updated++
}
}
return { updated, removed }
}
/** 移除 SQLite 中有记录但本地磁盘无对应文件的索引行 */
export async function purgeOrphanImageIndexRows(): Promise<number> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT key, backend_id FROM images
`).all() as Array<{ key: string, backend_id: string }>
let removed = 0
for (const row of rows) {
if (row.backend_id !== LOCAL_BACKEND_ID) continue
if (await fileExistsAtKey(row.key)) continue
const canonical = toCanonicalImageKey(row.key)
if (canonical && await fileExistsAtKey(canonical)) continue
deleteImageIndex(row.key)
removed++
}
return removed
}
export function getImageIndexRow(key: string): ImageIndexRow | null { export function getImageIndexRow(key: string): ImageIndexRow | null {
ensureStorageSchema() ensureStorageSchema()
const row = getDb().prepare(` const row = getDb().prepare(`
@@ -223,7 +129,7 @@ export function findImageKeyByDatePath(datePath: string): string | null {
const fallback = `${DEFAULT_FOLDER}/${datePath}` const fallback = `${DEFAULT_FOLDER}/${datePath}`
return validateImageKey(fallback) ? fallback : null return validateImageKey(fallback) ? fallback : null
} }
if (rows.length === 1) return preferIndexedImageKey(rows[0]!.key) if (rows.length === 1) return rows[0]!.key
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${datePath}`) const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${datePath}`)
if (exactDefault) return exactDefault.key if (exactDefault) return exactDefault.key
@@ -246,7 +152,7 @@ export function findImageKeyByFilename(filename: string): string | null {
const flatFallback = `${DEFAULT_FOLDER}/${filename}` const flatFallback = `${DEFAULT_FOLDER}/${filename}`
return validateImageKey(flatFallback) ? flatFallback : null return validateImageKey(flatFallback) ? flatFallback : null
} }
if (rows.length === 1) return preferIndexedImageKey(rows[0]!.key) if (rows.length === 1) return rows[0]!.key
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${filename}`) const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${filename}`)
if (exactDefault) return exactDefault.key if (exactDefault) return exactDefault.key
@@ -255,37 +161,55 @@ export function findImageKeyByFilename(filename: string): string | null {
return defaultFolder?.key ?? rows[0]!.key return defaultFolder?.key ?? rows[0]!.key
} }
async function collectImageKeysRecursive( async function readDirNames(path: string): Promise<string[]> {
dir: string,
keyPrefix: string
): Promise<string[]> {
const keys: string[] = []
try { try {
const entries = await fs.readdir(dir, { withFileTypes: true }) const entries = await fs.readdir(path, { withFileTypes: true })
for (const entry of entries) { return entries.filter(e => e.isDirectory()).map(e => e.name)
const name = String(entry.name)
if (entry.isDirectory()) {
const childKeys = await collectImageKeysRecursive(
join(dir, name),
`${keyPrefix}/${name}`
)
keys.push(...childKeys)
} else if (entry.isFile()) {
if (name.endsWith(META_SUFFIX)) continue
const key = `${keyPrefix}/${name}`
if (validateImageKey(key)) keys.push(key)
}
}
} catch { } catch {
return keys return []
}
} }
return keys async function readFileNames(path: string): Promise<string[]> {
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<string[]> { async function listLocalImageKeys(): Promise<string[]> {
const imagesRoot = join(getDataDir(), DEFAULT_FOLDER) const keys: string[] = []
return collectImageKeysRecursive(imagesRoot, DEFAULT_FOLDER) const dataDir = getDataDir()
const prefixes = await readDirNames(dataDir)
for (const prefix of prefixes) {
if (!isValidFolderName(prefix)) continue
const root = join(dataDir, prefix)
const rootFiles = await readFileNames(root)
for (const file of rootFiles) {
if (file.endsWith(META_SUFFIX)) continue
const key = `${prefix}/${file}`
if (validateImageKey(key)) keys.push(key)
}
const years = await readDirNames(root)
for (const year of years) {
if (!/^\d{4}$/.test(year)) continue
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<number> { export async function migrateLocalImagesToIndex(): Promise<number> {
@@ -307,17 +231,12 @@ export async function migrateLocalImagesToIndex(): Promise<number> {
const meta = await readLocalMeta(key) const meta = await readLocalMeta(key)
const uploadedAt = meta.uploadedAt ?? stat.mtime.toISOString() const uploadedAt = meta.uploadedAt ?? stat.mtime.toISOString()
const inferredType = contentTypeFromKey(key)
const contentType = meta.contentType && meta.contentType !== 'application/octet-stream'
? meta.contentType
: inferredType
insertImageIndex({ insertImageIndex({
key, key,
backendId: LOCAL_BACKEND_ID, backendId: LOCAL_BACKEND_ID,
userId: meta.userId ?? null, userId: meta.userId ?? null,
originalName: meta.originalName ?? key.split('/').pop() ?? 'image', originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
contentType, contentType: meta.contentType ?? 'application/octet-stream',
size: stat.size, size: stat.size,
uploadedAt uploadedAt
}) })
@@ -328,36 +247,28 @@ export async function migrateLocalImagesToIndex(): Promise<number> {
} }
export async function listFolders(): Promise<string[]> { export async function listFolders(): Promise<string[]> {
return [DEFAULT_FOLDER] 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)
return sortFolderNames(folders)
} }
export async function listFoldersForUser( export async function listFoldersForUser(
_userFilter?: number | 'admin' userFilter?: number | 'admin'
): Promise<string[]> { ): Promise<string[]> {
return [DEFAULT_FOLDER]
}
/** 修正存量索引中错误的 application/octet-stream */
export function repairLocalImageIndexMetadata(): number {
ensureStorageSchema() ensureStorageSchema()
const params: Array<string | number> = []
const userSql = buildUserFilterSql(userFilter, params)
const rows = getDb().prepare(` const rows = getDb().prepare(`
SELECT key, content_type FROM images WHERE content_type = 'application/octet-stream' SELECT DISTINCT folder FROM images WHERE 1=1${userSql} ORDER BY folder ASC
`).all() as Array<{ key: string, content_type: string }> `).all(...params) as Array<{ folder: string }>
let repaired = 0 const folders = rows.map(row => row.folder).filter(isValidFolderName)
const update = getDb().prepare(` return sortFolderNames(folders)
UPDATE images SET content_type = ? WHERE key = ?
`)
for (const row of rows) {
const inferred = contentTypeFromKey(row.key)
if (inferred !== 'application/octet-stream') {
update.run(inferred, row.key)
repaired++
}
}
return repaired
} }
function buildBackendFilterSql( function buildBackendFilterSql(
@@ -370,6 +281,7 @@ function buildBackendFilterSql(
} }
export async function countImages( export async function countImages(
folder?: string,
userFilter?: number | 'admin', userFilter?: number | 'admin',
backendId?: string backendId?: string
): Promise<number> { ): Promise<number> {
@@ -377,6 +289,11 @@ export async function countImages(
const params: Array<string | number> = [] const params: Array<string | number> = []
const clauses: string[] = ['1=1'] const clauses: string[] = ['1=1']
if (folder && isValidFolderName(folder)) {
clauses.push('folder = ?')
params.push(folder)
}
const userSql = buildUserFilterSql(userFilter, params) const userSql = buildUserFilterSql(userFilter, params)
const backendSql = buildBackendFilterSql(backendId, params) const backendSql = buildBackendFilterSql(backendId, params)
const row = getDb().prepare(` const row = getDb().prepare(`
@@ -405,6 +322,7 @@ function paginateSlice<T>(
export async function listImages(options: { export async function listImages(options: {
limit: number limit: number
page?: number page?: number
folder?: string
userFilter?: number | 'admin' userFilter?: number | 'admin'
backendId?: string backendId?: string
}): Promise<PaginatedResult<StoredImage>> { }): Promise<PaginatedResult<StoredImage>> {
@@ -412,6 +330,11 @@ export async function listImages(options: {
const params: Array<string | number> = [] const params: Array<string | number> = []
const clauses: string[] = ['1=1'] 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 userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params) const backendSql = buildBackendFilterSql(options.backendId, params)
const rows = getDb().prepare(` const rows = getDb().prepare(`
@@ -432,6 +355,7 @@ export async function searchImages(options: {
query: string query: string
limit: number limit: number
page?: number page?: number
folder?: string
userFilter?: number | 'admin' userFilter?: number | 'admin'
backendId?: string backendId?: string
}): Promise<PaginatedResult<StoredImage>> { }): Promise<PaginatedResult<StoredImage>> {
@@ -440,6 +364,11 @@ export async function searchImages(options: {
const params: Array<string | number> = [] const params: Array<string | number> = []
const clauses: string[] = ['1=1'] 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 userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params) const backendSql = buildBackendFilterSql(options.backendId, params)
const rows = getDb().prepare(` const rows = getDb().prepare(`
+14 -18
View File
@@ -1,29 +1,31 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { generateImageKey, validateImageKey } from './image-key' import { generateImageKey, sortFolderNames, validateImageKey } from './image-key'
describe('validateImageKey', () => { describe('validateImageKey', () => {
it('accepts valid date-layout keys', () => { it('accepts valid date-layout keys', () => {
expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true) expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/2025/01/photo.jpg')).toBe(true)
}) })
it('accepts valid flat-layout keys', () => { it('accepts valid flat-layout keys', () => {
expect(validateImageKey('images/abc123.webp')).toBe(true) expect(validateImageKey('images/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/photo.jpg')).toBe(true)
}) })
it('accepts migrated nested paths under images/', () => { it('sorts images first then alphabetically', () => {
expect(validateImageKey('images/file/111.webp')).toBe(true) expect(sortFolderNames(['blog', 'images', 'twikoo'])).toEqual(['images', 'blog', 'twikoo'])
expect(validateImageKey('images/2026/08/28/123.webp')).toBe(true) expect(sortFolderNames(['blog'])).toEqual(['images', 'blog'])
expect(validateImageKey('images/twikoo/2025/01/photo.jpg')).toBe(true)
}) })
it('rejects non-images top-level and path traversal', () => { it('rejects path traversal and malformed keys', () => {
expect(validateImageKey('')).toBe(false) expect(validateImageKey('')).toBe(false)
expect(validateImageKey('/images/2026/08/a.webp')).toBe(false) expect(validateImageKey('/images/2026/08/a.webp')).toBe(false)
expect(validateImageKey('images/2026/08/../secret.webp')).toBe(false) expect(validateImageKey('images/2026/08/../secret.webp')).toBe(false)
expect(validateImageKey('images//2026/08/a.webp')).toBe(false) expect(validateImageKey('images//2026/08/a.webp')).toBe(false)
expect(validateImageKey('blog/2026/08/a.webp')).toBe(false) expect(validateImageKey('api/2026/08/a.webp')).toBe(false)
expect(validateImageKey('twikoo/photo.jpg')).toBe(false) expect(validateImageKey('images/26/8/a.webp')).toBe(false)
expect(validateImageKey('images/2026/08/a.txt')).toBe(false) expect(validateImageKey('images/2026/08/a.txt')).toBe(false)
expect(validateImageKey('images/2026/a.webp')).toBe(false)
}) })
}) })
@@ -31,20 +33,14 @@ describe('generateImageKey', () => {
const date = new Date('2026-08-15T12:00:00Z') const date = new Date('2026-08-15T12:00:00Z')
it('generates date-layout keys by default', () => { it('generates date-layout keys by default', () => {
const key = generateImageKey('image/webp', date) const key = generateImageKey('image/webp', date, 'images')
expect(key).toMatch(/^images\/2026\/08\/[A-Za-z0-9]{12}\.webp$/) expect(key).toMatch(/^images\/2026\/08\/[A-Za-z0-9]+\.webp$/)
expect(validateImageKey(key)).toBe(true) expect(validateImageKey(key)).toBe(true)
}) })
it('generates flat-layout keys when requested', () => { it('generates flat-layout keys when requested', () => {
const key = generateImageKey('image/webp', date, 'flat') const key = generateImageKey('image/webp', date, 'images', 'flat')
expect(key).toMatch(/^images\/[A-Za-z0-9]{12}\.webp$/) expect(key).toMatch(/^images\/[A-Za-z0-9]+\.webp$/)
expect(validateImageKey(key)).toBe(true) expect(validateImageKey(key)).toBe(true)
}) })
it('uses 12-character random IDs', () => {
const key = generateImageKey('image/webp', date, 'flat')
const id = key.split('/')[1]?.replace('.webp', '') ?? ''
expect(id.length).toBe(12)
})
}) })
+54 -28
View File
@@ -13,6 +13,20 @@ export const DEFAULT_FOLDER = 'images'
export type StorageLayout = 'date' | 'flat' export type StorageLayout = 'date' | 'flat'
/** 避免与站点路由 / 静态资源冲突 */
const RESERVED_FOLDERS = new Set([
'api',
'stats',
'_nuxt',
'__nuxt',
'assets',
'public',
'favicon',
'robots',
'sitemap',
'sw'
])
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
function generateRandomId(length = 12): string { function generateRandomId(length = 12): string {
@@ -26,26 +40,51 @@ export function mimeToExtension(mime: AllowedMimeType): string {
return EXT_MAP[mime] return EXT_MAP[mime]
} }
/** 路径段:字母数字开头,允许 - _ .,最长 64 */ /** 目录名:字母数字开头,允许 - _,最长 32;不含路径分隔符 */
function isValidPathSegment(segment: string): boolean { export function isValidFolderName(name: string): boolean {
if (!segment || segment.length > 64) return false if (!name || typeof name !== 'string') return false
if (segment === '.' || segment === '..') return false if (name.length > 32) return false
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment) if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) return false
return !RESERVED_FOLDERS.has(name.toLowerCase())
}
export function normalizeFolderName(raw: string | undefined | null): string {
const trimmed = raw?.trim() || DEFAULT_FOLDER
return trimmed
}
/** 默认目录 images 始终排在首位,其余按字母序 */
export function sortFolderNames(folders: string[]): string[] {
const seen = new Set<string>()
const valid: string[] = []
for (const folder of folders) {
if (!isValidFolderName(folder) || seen.has(folder)) continue
seen.add(folder)
valid.push(folder)
}
if (!seen.has(DEFAULT_FOLDER)) {
valid.push(DEFAULT_FOLDER)
}
const rest = valid
.filter(folder => folder !== DEFAULT_FOLDER)
.sort((a, b) => a.localeCompare(b))
return [DEFAULT_FOLDER, ...rest]
} }
export function generateImageKey( export function generateImageKey(
contentType: AllowedMimeType, contentType: AllowedMimeType,
date = new Date(), date = new Date(),
prefix = DEFAULT_FOLDER,
layout: StorageLayout = 'date' layout: StorageLayout = 'date'
): string { ): string {
const id = generateRandomId(12) const id = generateRandomId(12)
const ext = mimeToExtension(contentType) const ext = mimeToExtension(contentType)
if (layout === 'flat') { if (layout === 'flat') {
return `${DEFAULT_FOLDER}/${id}.${ext}` return `${prefix}/${id}.${ext}`
} }
const year = date.getUTCFullYear() const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0') const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${DEFAULT_FOLDER}/${year}/${month}/${id}.${ext}` return `${prefix}/${year}/${month}/${id}.${ext}`
} }
export function validateImageKey(key: string): boolean { export function validateImageKey(key: string): boolean {
@@ -57,34 +96,21 @@ export function validateImageKey(key: string): boolean {
const parts = key.split('/') const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false if (parts.length < 2 || parts.some(part => !part)) return false
if (parts[0] !== DEFAULT_FOLDER) return false const folder = parts[0]!
if (!isValidFolderName(folder)) return false
const filename = parts[parts.length - 1]! const filename = parts[parts.length - 1]!
if (!IMAGE_EXT_PATTERN.test(filename)) return false if (!IMAGE_EXT_PATTERN.test(filename)) return false
for (let i = 1; i < parts.length - 1; i++) { if (parts.length === 2) {
if (!isValidPathSegment(parts[i]!)) return false
}
return true return true
} }
/** 将遗留顶层目录 key(如 blog/foo.webp)映射为 images/blog/foo.webp */ if (parts.length === 4) {
export function toCanonicalImageKey(key: string): string | null { const year = parts[1]!
if (validateImageKey(key)) return key const month = parts[2]!
if (!key || key.startsWith('/') || key.includes('..') || key.includes('//')) return null return /^\d{4}$/.test(year) && /^\d{2}$/.test(month)
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return null
if (parts[0] === DEFAULT_FOLDER) return null
const filename = parts[parts.length - 1]!
if (!IMAGE_EXT_PATTERN.test(filename)) return null
for (let i = 0; i < parts.length - 1; i++) {
if (!isValidPathSegment(parts[i]!)) return null
} }
const canonical = `${DEFAULT_FOLDER}/${key}` return false
return validateImageKey(canonical) ? canonical : null
} }
+1 -1
View File
@@ -13,7 +13,7 @@ describe('toPublicImagePath', () => {
it('returns basename when hideFolder is true', () => { it('returns basename when hideFolder is true', () => {
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('a.webp') expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('images/blog/2026/08/a.webp', true)).toBe('a.webp') expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('images/a.webp', true)).toBe('a.webp') expect(toPublicImagePath('images/a.webp', true)).toBe('a.webp')
}) })
}) })
+7 -1
View File
@@ -62,6 +62,7 @@ export async function processSingleImageUpload(
input: { input: {
bytes: Uint8Array bytes: Uint8Array
filename?: string filename?: string
prefix?: string
source?: LogSource source?: LogSource
userId?: number | null userId?: number | null
} }
@@ -122,7 +123,12 @@ export async function processSingleImageUpload(
const uploadedAt = new Date().toISOString() const uploadedAt = new Date().toISOString()
const layout = getStorageLayout(event) const layout = getStorageLayout(event)
const key = generateImageKey(compressed.mime, new Date(uploadedAt), layout) const key = generateImageKey(
compressed.mime,
new Date(uploadedAt),
input.prefix ?? 'images',
layout
)
let backendId: string let backendId: string
try { try {
+1 -1
View File
@@ -20,7 +20,7 @@ import { getBackendRowForKey } from './storage/resolver'
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证 * - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
*/ */
const ERROR_CACHE_CONTROL = 'private, no-cache, max-age=0' const ERROR_CACHE_CONTROL = 'public, max-age=60'
function getAllowedHosts(event: H3Event): Set<string> { function getAllowedHosts(event: H3Event): Set<string> {
const hosts = new Set<string>() const hosts = new Set<string>()
+9 -79
View File
@@ -1,15 +1,10 @@
import type { ReadStream } from 'node:fs' import type { ReadStream } from 'node:fs'
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import type { Readable } from 'node:stream' import type { Readable } from 'node:stream'
import { contentTypeFromKey } from './content-type'
import { getDataDir } from './data-dir'
import { import {
countImages as countImagesFromIndex, countImages as countImagesFromIndex,
deleteImageIndex, deleteImageIndex,
getFolderStorageStats as getFolderStorageStatsFromIndex, getFolderStorageStats as getFolderStorageStatsFromIndex,
getImageIndexAsStored, getImageIndexAsStored,
getImageIndexRow,
getUserScopedStorageStats as getUserScopedStorageStatsFromIndex, getUserScopedStorageStats as getUserScopedStorageStatsFromIndex,
insertImageIndex, insertImageIndex,
listFolders as listFoldersFromIndex, listFolders as listFoldersFromIndex,
@@ -18,7 +13,6 @@ import {
listImages as listImagesFromIndex, listImages as listImagesFromIndex,
searchImages as searchImagesFromIndex searchImages as searchImagesFromIndex
} from './image-index' } from './image-index'
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
import { import {
getActiveBackend, getActiveBackend,
getBackendForKey getBackendForKey
@@ -39,58 +33,6 @@ export type {
export { getDataDir } from './data-dir' export { getDataDir } from './data-dir'
function keyToFilePath(key: string): string {
return join(getDataDir(), ...key.split('/'))
}
async function fileExistsAtKey(key: string): Promise<boolean> {
try {
await fs.access(keyToFilePath(key))
return true
} catch {
return false
}
}
/** 删除/变更前解析 key:接受遗留 blog/… 并映射到 images/blog/… */
export async function resolveStorageImageKey(key: string): Promise<string | null> {
if (!key || typeof key !== 'string') return null
if (validateImageKey(key)) {
if (await fileExistsAtKey(key) || getImageIndexRow(key)) return key
return null
}
if (getImageIndexRow(key)) {
const canonical = toCanonicalImageKey(key)
if (canonical && (await fileExistsAtKey(canonical) || getImageIndexRow(canonical))) {
return canonical
}
if (await fileExistsAtKey(key)) return key
return canonical ?? key
}
const canonical = toCanonicalImageKey(key)
if (canonical && (await fileExistsAtKey(canonical) || getImageIndexRow(canonical))) {
return canonical
}
return null
}
function deleteImageIndexAliases(storageKey: string, requestedKey?: string) {
deleteImageIndex(storageKey)
if (requestedKey && requestedKey !== storageKey) {
deleteImageIndex(requestedKey)
}
if (storageKey.startsWith(`${DEFAULT_FOLDER}/`)) {
const legacy = storageKey.slice(DEFAULT_FOLDER.length + 1)
if (legacy && legacy !== storageKey) {
deleteImageIndex(legacy)
}
}
}
export async function putImage( export async function putImage(
key: string, key: string,
bytes: Uint8Array, bytes: Uint8Array,
@@ -111,28 +53,13 @@ export async function putImage(
} }
export async function headImage(key: string): Promise<StoredImage | null> { export async function headImage(key: string): Promise<StoredImage | null> {
const indexed = getImageIndexAsStored(key)
if (indexed) return indexed
const backend = await getBackendForKey(key) const backend = await getBackendForKey(key)
const stored = await backend.head(key) const stored = await backend.head(key)
if (!stored) return null if (!stored) return null
const indexed = getImageIndexAsStored(key)
if (indexed) {
let contentType = indexed.contentType
if (contentType === 'application/octet-stream') {
contentType = stored.contentType !== 'application/octet-stream'
? stored.contentType
: contentTypeFromKey(key)
}
return {
...stored,
backendId: backend.id,
originalName: indexed.originalName,
contentType,
uploadedAt: indexed.uploadedAt,
userId: indexed.userId
}
}
insertImageIndex({ insertImageIndex({
key: stored.key, key: stored.key,
backendId: backend.id, backendId: backend.id,
@@ -154,10 +81,10 @@ export async function createImageStream(
return backend.createStream(key, range) return backend.createStream(key, range)
} }
export async function deleteImage(key: string, requestedKey?: string): Promise<void> { export async function deleteImage(key: string): Promise<void> {
const backend = await getBackendForKey(key) const backend = await getBackendForKey(key)
await backend.delete(key) await backend.delete(key)
deleteImageIndexAliases(key, requestedKey) deleteImageIndex(key)
} }
export async function listFolders(): Promise<string[]> { export async function listFolders(): Promise<string[]> {
@@ -170,10 +97,11 @@ export async function listImageKeys(): Promise<string[]> {
} }
export async function countImages( export async function countImages(
folder?: string,
userFilter?: number | 'admin', userFilter?: number | 'admin',
backendId?: string backendId?: string
): Promise<number> { ): Promise<number> {
return countImagesFromIndex(userFilter, backendId) return countImagesFromIndex(folder, userFilter, backendId)
} }
export async function getFolderStorageStats( export async function getFolderStorageStats(
@@ -201,6 +129,7 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
export async function listImages(options: { export async function listImages(options: {
limit: number limit: number
page?: number page?: number
folder?: string
userFilter?: number | 'admin' userFilter?: number | 'admin'
backendId?: string backendId?: string
}): Promise<PaginatedResult<StoredImage>> { }): Promise<PaginatedResult<StoredImage>> {
@@ -211,6 +140,7 @@ export async function searchImages(options: {
query: string query: string
limit: number limit: number
page?: number page?: number
folder?: string
userFilter?: number | 'admin' userFilter?: number | 'admin'
backendId?: string backendId?: string
}): Promise<PaginatedResult<StoredImage>> { }): Promise<PaginatedResult<StoredImage>> {
+23 -2
View File
@@ -1,7 +1,6 @@
import { createReadStream, promises as fs } from 'node:fs' import { createReadStream, promises as fs } from 'node:fs'
import type { ReadStream } from 'node:fs' import type { ReadStream } from 'node:fs'
import { dirname, join } from 'node:path' import { dirname, join } from 'node:path'
import { contentTypeFromKey } from '../content-type'
import { getDataDir } from '../data-dir' import { getDataDir } from '../data-dir'
import { validateImageKey } from '../image-key' import { validateImageKey } from '../image-key'
import type { StoredImage, StoredImageMeta, StorageBackend } from './types' import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
@@ -12,6 +11,27 @@ function keyToFilePath(key: string): string {
return join(getDataDir(), ...key.split('/')) 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<Partial<StoredImageMeta>> { async function readMeta(key: string): Promise<Partial<StoredImageMeta>> {
try { try {
const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8') const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
@@ -37,10 +57,11 @@ export class LocalStorageBackend implements StorageBackend {
this.publicUrl = publicUrl this.publicUrl = publicUrl
} }
async put(key: string, bytes: Uint8Array, _meta: StoredImageMeta): Promise<void> { async put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise<void> {
const filePath = keyToFilePath(key) const filePath = keyToFilePath(key)
await fs.mkdir(dirname(filePath), { recursive: true }) await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, bytes) await fs.writeFile(filePath, bytes)
await fs.writeFile(filePath + META_SUFFIX, JSON.stringify(meta))
} }
async head(key: string): Promise<StoredImage | null> { async head(key: string): Promise<StoredImage | null> {
+1 -1
View File
@@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({ export default defineConfig({
test: { test: {
environment: 'node', environment: 'node',
include: ['server/**/*.test.ts'] include: ['server/**/*.test.ts', 'scripts/**/*.test.ts']
} }
}) })