release: v1.2.0 — unified images/ storage and legacy migration

Consolidate local images under data/images/, add docker migrate CLI,
startup index sync with orphan cleanup, and remove upload folder UI/API.
This commit is contained in:
O96u committed 2026-08-28 17:18:03 +08:00
1 parent 3dee0437e6
commit 469d3be735
50 files changed
+1273 -956

No files matched your search

+20 -2
View File
@@ -2,6 +2,23 @@
本仓库自 **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
### 修复
@@ -123,6 +140,7 @@
- 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单
- 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户)
- Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`)
- Docker 升级到 v1.2.0 前迁移遗留目录:`docker exec pichost migrate` / `migrate --apply`
### 改进
@@ -159,9 +177,9 @@
### 路线图
- **v1.1.2**(当前 `main`):操作日志、图库存储筛选、上传限流
- **v1.2.0**(开发中):统一 `images/` 存储、遗留目录迁移 CLI、启动索引同步
[1.1.2]: https://github.com/O96u/PicHost/releases/tag/v1.1.2
[1.2.0]: https://github.com/O96u/PicHost/releases/tag/v1.2.0
[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.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4
+1
View File
@@ -24,6 +24,7 @@ ENV NODE_ENV=production \
COPY --from=build /app/.output ./.output
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
RUN chmod +x /docker-entrypoint.sh
+33 -10
View File
@@ -32,7 +32,7 @@
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.5** — local disk + multi-backend storage, bidirectional dual-domain isolation, [`/storage`](docs/README.md) UI, hybrid URLs |
| [**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) |
| [**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.
@@ -59,14 +59,14 @@ git checkout cloudflare
## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths
- **Drag, click, or Ctrl+V paste** to upload; all files under `images/`
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL
- **Multi-user** — username/password login; optional registration; users see only their images
- **Upload preferences** (home card flip): client-side compression, auto-copy links, per-user auto-delete
- **Multi-backend storage**: local disk + S3-compatible (Cloudflare R2 / Tencent COS / Alibaba OSS / AWS S3); admins manage backends at **Storage** (`/storage`)
- **Hybrid URLs**: `proxy` (same-origin via PicHost) or `public` (302 to CDN / bucket URL)
- **Gallery** — browse, search, batch delete; admin sees uploader badges
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count
- **Stats** — upload/delete metrics, upload source breakdown; admin sees registered user count
- **API** — global token (admin) + per-user tokens; in-app cURL snippets
- **Twikoo** — `POST /api/index.php` (`image` + `token`)
- **Zero-config Docker** — first-run web wizard, no secrets required upfront
@@ -95,6 +95,27 @@ docker exec pichost reset-password <username> # admin or regular user
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`).
### Local development
@@ -130,19 +151,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 |
| Direct link `GET /images/...` | None (Referer rules) | Public if URL is known |
Regular users are limited to the `images/` folder; custom `folder` requires admin or global token.
All uploads are stored under `images/`.
## Storage layout
```
/data
├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
├── images/ # Default upload folder (when using local backend)
├── blog/ # Example custom folder
└── twikoo/ # Twikoo comment images
└── images/ # All images (local backend); may include migrated subpaths
```
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.
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.
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
@@ -158,7 +179,6 @@ Upload example:
```bash
curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png"
```
@@ -222,13 +242,16 @@ npm run start # Run .output
npm run lint # ESLint
npm run typecheck # Type check
npm test # Unit tests
npm run reset-password # Reset password (optional username)
npm run migrate # Preview legacy dir migration (-- --apply to run)
```
## Roadmap
| Version | Plan |
| ---------- | -------------------------------------------------------------- |
| **v1.1.5** | Gallery preview reliability: auto-retry, storage verify, no-cache errors (current `main`) |
| **v1.2.0** | Unified `images/` storage, CLI migration, startup index sync, upload source stats (in progress) |
| **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.2** | Activity logs, gallery storage filter, upload rate limits |
+36 -11
View File
@@ -32,7 +32,7 @@
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.5** 主线:本地磁盘 + 多对象存储、双域名双向隔离、[`/storage`](docs/README.md) 存储管理、混合直链 |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.2.0**(开发中)— 统一 `images/` 存储、多后端、双域名隔离、[`/storage`](docs/README.md)、[`迁移指南`](docs/migration-to-v1.2.md) |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
日常使用、需要管理多种存储后端,请直接用 **main**。
@@ -59,14 +59,14 @@ git checkout cloudflare
## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;图片统一存入 `images/`
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除
- **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端
- **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址)
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量
- **统计**:上传/删除趋势、上传来源分布;管理员额外显示注册用户数量
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
- **Twikoo**:`POST /api/index.php`(`image` + `token`)
- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥
@@ -100,6 +100,29 @@ docker exec pichost 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`)。
### 本地开发
@@ -135,19 +158,19 @@ npm run dev
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
普通用户上传目录固定为 `images/`;自定义 `folder` 仅管理员或全局 Token 可用。
所有上传统一存入 `images/` 目录。
## 存储结构
```
/data
├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
├── images/ # 默认上传目录(本地后端时)
├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片
└── images/ # 全部图片(本地后端时);可含迁移子路径如 file/、年/月/日/
```
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。云存储时文件在对应桶内,SQLite `images` 表记录 `backend_id` 与 `key`。备份请包含整个 `/data` 与云桶数据。
路径规则:新上传为 `images/随机ID.webp` 或 `images/年/月/随机ID.webp`;迁移遗留可为更深子路径(如 `images/blog/...`)。元数据在 SQLite `images` 表;云存储时文件在对应桶内。备份请包含 `data/images/`、`pichost.db` 与云桶数据。
从 v1.1.x 升级且存在 `data/blog/` 等并列目录时,见 [升级到 v1.2.0](#升级到-v120本地磁盘遗留目录) 或 [`docs/migration-to-v1.2.md`](docs/migration-to-v1.2.md)。
## API 概览
@@ -163,7 +186,6 @@ npm run dev
```bash
curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png"
```
@@ -226,14 +248,17 @@ npm run build # 生产构建
npm run start # 运行 .output
npm run lint # ESLint
npm run typecheck # 类型检查
npm test # 单元测试
npm test # 单元测试
npm run reset-password # 重置密码(可选用户名参数)
npm run migrate # 预览遗留目录迁移(加 -- --apply 执行)
```
## 路线图
| 版本 | 说明 |
| ---------- | ------------------------------------------------- |
| **v1.1.5** | 图库预览可靠性:自动重试、出图前校验、错误响应禁缓存(当前 main) |
| **v1.2.0** | 统一 `images/` 存储、遗留目录 CLI 迁移、启动索引同步、上传来源统计(开发中) |
| **v1.1.5** | 图库预览可靠性:自动重试、出图前校验、错误响应禁缓存 |
| **v1.1.4** | 存储路径扁平/分组、basename 外链、双域名双向隔离 |
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示 |
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
+53
View File
@@ -0,0 +1,53 @@
<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>
+7 -4
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
type CopyFormat = 'url' | 'markdown' | 'html'
import type { CopyFormat } from '~/composables/useUploadPreferences'
const props = withDefaults(defineProps<{
image: ImageItem
@@ -25,9 +24,13 @@ const emit = defineEmits<{
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const { copyFormat } = useUploadPreferences()
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]
@@ -231,7 +234,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="() => { copyFormat = item.value }"
@click="selectCopyFormat(item.value)"
/>
</div>
-111
View File
@@ -1,111 +0,0 @@
<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>
+20 -58
View File
@@ -12,20 +12,13 @@ export function useImageList() {
const totalPages = ref(1)
const total = ref(0)
const loading = ref(false)
const folderTotal = ref<number | null>(null)
const totalCount = ref<number | null>(null)
const loadingTotal = ref(false)
const searchQuery = ref('')
const activeSearch = ref('')
const activeFolder = ref('all')
const activeStorageBackend = ref('all')
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> {
const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return {}
@@ -33,16 +26,7 @@ export function useImageList() {
}
function listQueryParams(): Record<string, string> {
return {
...folderQueryParams(),
...storageQueryParams()
}
}
function matchesActiveFolder(key: string) {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`)
return storageQueryParams()
}
function matchesActiveStorage(item: ImageItem) {
@@ -74,9 +58,9 @@ export function useImageList() {
credentials: 'include',
query: listQueryParams()
})
folderTotal.value = data.total
totalCount.value = data.total
} catch {
folderTotal.value = null
totalCount.value = null
} finally {
loadingTotal.value = false
}
@@ -138,27 +122,13 @@ export function useImageList() {
await Promise.all([fetchList(1), fetchTotal()])
}
async function initializeList(folder = 'all') {
const next = folder.trim() || 'all'
activeFolder.value = next
async function initializeList() {
page.value = 1
activeSearch.value = ''
searchQuery.value = ''
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) {
const next = backendId.trim() || 'all'
if (next === activeStorageBackend.value) return
@@ -192,14 +162,12 @@ export function useImageList() {
}
function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item =>
matchesActiveFolder(item.key) && matchesActiveStorage(item)
)
const matched = newItems.filter(item => matchesActiveStorage(item))
if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value]
total.value += matched.length
if (folderTotal.value !== null) {
folderTotal.value += matched.length
if (totalCount.value !== null) {
totalCount.value += matched.length
}
}
@@ -210,8 +178,8 @@ export function useImageList() {
if (removed > 0) {
total.value = Math.max(0, total.value - removed)
}
if (folderTotal.value !== null && removed > 0) {
folderTotal.value = Math.max(0, folderTotal.value - removed)
if (totalCount.value !== null && removed > 0) {
totalCount.value = Math.max(0, totalCount.value - removed)
}
}
@@ -223,34 +191,30 @@ export function useImageList() {
})
const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
const storageLabel = activeStorageLabel.value
if (loadingTotal.value && folderTotal.value === null) {
if (loadingTotal.value && totalCount.value === null) {
return storageLabel
? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}`
: `${folderLabel} · ${t('stats.counting')}`
? `${storageLabel} · ${t('stats.counting')}`
: t('stats.counting')
}
if (activeSearch.value) {
if (storageLabel) {
return t('stats.summarySearchStorage', {
folder: folderLabel,
storage: storageLabel,
q: activeSearch.value,
total: total.value
total: total.value,
storage: storageLabel
})
}
return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value })
return t('stats.summarySearch', { q: activeSearch.value, total: total.value })
}
const count = folderTotal.value === null ? total.value : folderTotal.value
const count = totalCount.value === null ? total.value : totalCount.value
if (storageLabel) {
return t('stats.summaryTotalStorage', {
folder: folderLabel,
storage: storageLabel,
total: count
total: count,
storage: storageLabel
})
}
return t('stats.summaryTotal', { folder: folderLabel, total: count })
return t('stats.summaryTotal', { total: count })
})
return {
@@ -259,7 +223,6 @@ export function useImageList() {
totalPages,
total,
loading,
activeFolder,
activeStorageBackend,
storageBackendOptions,
searchQuery,
@@ -270,7 +233,6 @@ export function useImageList() {
fetchTotal,
refreshList,
initializeList,
setActiveFolder,
setActiveStorageBackend,
loadStorageBackendOptions,
submitSearch,
+1 -5
View File
@@ -17,8 +17,7 @@ export function useImageUpload() {
async function uploadFiles(
files: File[],
folder = 'images',
options?: { notify?: boolean, includeFolder?: boolean }
options?: { notify?: boolean }
) {
if (uploading.value) return
@@ -55,9 +54,6 @@ export function useImageUpload() {
}))
const formData = new FormData()
if (options?.includeFolder !== false) {
formData.append('folder', folder.trim() || 'images')
}
for (const file of prepared) {
formData.append('files', file, file.name)
}
+1 -10
View File
@@ -3,7 +3,6 @@ const STORAGE_KEY = 'pichost.upload-preferences'
export type CopyFormat = 'url' | 'markdown' | 'html'
export interface UploadPreferences {
defaultFolder: string
compressEnabled: boolean
autoCopyMarkdown: boolean
copyFormat: CopyFormat
@@ -11,7 +10,6 @@ export interface UploadPreferences {
}
const DEFAULTS: UploadPreferences = {
defaultFolder: 'images',
compressEnabled: false,
autoCopyMarkdown: false,
copyFormat: 'markdown',
@@ -36,9 +34,6 @@ function readStored(): UploadPreferences {
if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
return {
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
? parsed.defaultFolder.trim()
: DEFAULTS.defaultFolder,
compressEnabled: parsed.compressEnabled === true,
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
@@ -55,7 +50,6 @@ function writeStored(prefs: UploadPreferences) {
}
export function useUploadPreferences() {
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
@@ -64,7 +58,6 @@ export function useUploadPreferences() {
function loadPreferences() {
const stored = readStored()
defaultFolder.value = stored.defaultFolder
compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat
@@ -74,7 +67,6 @@ export function useUploadPreferences() {
function savePreferences() {
writeStored({
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
compressEnabled: compressEnabled.value,
autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value,
@@ -82,14 +74,13 @@ export function useUploadPreferences() {
})
}
watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
if (loaded.value) {
savePreferences()
}
})
return {
defaultFolder,
compressEnabled,
autoCopyMarkdown,
copyFormat,
+13 -60
View File
@@ -52,34 +52,13 @@ const authHeader = computed(() =>
const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
const apiDocs = computed<ApiDocItem[]>(() => {
const uploadDescription = isAdmin.value
? 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" \\
const uploadDescription = t('api.docs.upload.desc')
const uploadCurl = `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "image=@./demo.png"`
const listDescription = isAdmin.value
? 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" \\
const listDescription = t('api.docs.list.desc')
const listCurl = `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}`
return [
@@ -156,10 +135,12 @@ function methodBadgeColor(method: ApiDocItem['method']) {
switch (method) {
case 'GET':
return 'info' as const
case 'POST':
return 'warning' as const
case 'DELETE':
return 'error' as const
default:
return 'primary' as const
return 'neutral' as const
}
}
@@ -219,15 +200,6 @@ 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() {
confirmRegenerateOpen.value = false
}
@@ -404,18 +376,7 @@ watch(isAuthenticated, async (authed, prev) => {
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }}
</p>
<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>
<ApiCurlBlock :code="doc.curl" />
</div>
</article>
</div>
@@ -431,7 +392,7 @@ watch(isAuthenticated, async (authed, prev) => {
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<UBadge
color="neutral"
:color="methodBadgeColor(twikooDoc.method)"
variant="subtle"
size="sm"
>
@@ -454,18 +415,10 @@ watch(isAuthenticated, async (authed, prev) => {
<p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }}
</p>
<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(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>
<ApiCurlBlock
:code="twikooDoc.curl"
:scrollable="false"
/>
</div>
</div>
</section>
+2 -29
View File
@@ -4,17 +4,14 @@ import type { ImageItem } from '~/types/image'
const { uploading, progressItems, uploadFiles } = useImageUpload()
const {
defaultFolder,
autoCopyMarkdown,
copyFormat,
loadPreferences
} = useUploadPreferences()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin }
= useAuth()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
const toast = useToast()
const { t } = useI18n()
const folderOptions = ref<string[]>(['images'])
const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set())
const emptySelection = ref(new Set<string>())
@@ -30,22 +27,9 @@ 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() {
try {
loadPreferences()
if (isAdmin.value) {
await loadFolders()
}
} catch (error: unknown) {
handleAuthError(error)
}
@@ -112,20 +96,9 @@ async function handleUpload(files: File[]) {
}
try {
const uploadTarget = isAdmin.value
? (defaultFolder.value.trim() || 'images')
: 'images'
const result = await uploadFiles(files, uploadTarget, {
includeFolder: isAdmin.value
})
const result = await uploadFiles(files)
if (result?.items.length) {
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]) {
const first = result.items[0]
const text
+1 -4
View File
@@ -38,8 +38,7 @@ const actionItems = computed(() => [
const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceApi'), value: 'api' as const },
{ label: t('logs.sourceTwikoo'), value: 'twikoo' as const }
{ label: t('logs.sourceApi'), value: 'api' as const }
])
const userItems = computed(() => {
@@ -93,8 +92,6 @@ function sourceLabel(source: LogSource) {
return t('logs.sourceWeb')
case 'api':
return t('logs.sourceApi')
case 'twikoo':
return t('logs.sourceTwikoo')
}
}
+34 -135
View File
@@ -27,12 +27,10 @@ interface SettingsResponse {
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const { defaultFolder, loadPreferences } = useUploadPreferences()
const toast = useToast()
const { t } = useI18n()
const settings = ref<SettingsResponse | null>(null)
const folderOptions = ref<string[]>(['images'])
const savingServer = ref(false)
const refererDraft = ref('')
@@ -42,7 +40,6 @@ const hideFolderInUrlDraft = ref(false)
const storageUseDatePathDraft = ref(true)
const allowRegistrationDraft = ref(false)
const domainSeparationDraft = ref(false)
const hideFolderConfirmOpen = ref(false)
interface ReleaseCheckResponse {
currentVersion: string
@@ -53,10 +50,6 @@ interface ReleaseCheckResponse {
const releaseCheck = ref<ReleaseCheckResponse | null>(null)
const folderSelectItems = computed(() =>
folderOptions.value.map(folder => ({ label: folder, value: folder }))
)
const hasServerChanges = computed(() => {
if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts
@@ -131,28 +124,6 @@ function handlePatchError(error: unknown, fallback: string) {
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') {
const enabled = next === true
domainSeparationDraft.value = enabled
@@ -186,16 +157,6 @@ 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() {
try {
const data = await $fetch<SettingsResponse>('/api/settings', {
@@ -225,8 +186,7 @@ async function loadPage() {
await navigateTo('/')
return
}
loadPreferences()
await Promise.all([loadSettings(), loadFolders(), checkLatestRelease()])
await Promise.all([loadSettings(), checkLatestRelease()])
}
onMounted(async () => {
@@ -354,7 +314,7 @@ watch(isAuthenticated, async (authed, prev) => {
:model-value="hideFolderInUrlDraft"
class="mt-0.5 shrink-0"
:disabled="settings.hideFolderInUrlSource === 'env'"
@update:model-value="onHideFolderDraftChange"
@update:model-value="(v) => hideFolderInUrlDraft = v === true"
/>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
@@ -375,50 +335,11 @@ watch(isAuthenticated, async (authed, prev) => {
</label>
</div>
<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="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">
<h3 class="text-sm font-semibold">
{{ t('settings.domainSettings') }}
</h3>
<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">
{{ t('settings.domainSettings') }}
</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">
<UCheckbox
:model-value="domainSeparationDraft"
@@ -469,7 +390,7 @@ watch(isAuthenticated, async (authed, prev) => {
<UInput
v-model="siteBaseUrlDraft"
:placeholder="t('settings.siteBaseUrlPlaceholder')"
class="w-full max-w-xl font-mono text-sm"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.siteBaseUrl"
@@ -497,7 +418,7 @@ watch(isAuthenticated, async (authed, prev) => {
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full max-w-xl font-mono text-sm"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
@@ -529,7 +450,7 @@ watch(isAuthenticated, async (authed, prev) => {
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full max-w-xl font-mono text-sm"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
@@ -541,6 +462,31 @@ watch(isAuthenticated, async (authed, prev) => {
</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>
@@ -618,57 +564,11 @@ watch(isAuthenticated, async (authed, prev) => {
</div>
</section>
</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>
</template>
<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 {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.settings-referer-field {
flex: 1;
min-height: 0;
min-width: 0;
}
@@ -680,7 +580,6 @@ watch(isAuthenticated, async (authed, prev) => {
}
.settings-referer-field :deep(textarea) {
height: 100%;
min-height: 5.5rem;
resize: none;
}
+55 -123
View File
@@ -12,17 +12,7 @@ interface StatsResponse {
bySource: {
web: number
api: number
twikoo: number
}
byFolder: Array<{
folder: string
count: number
bytes: number
}>
byFolderUploads: Array<{
folder: string
count: number
}>
}
const {
@@ -38,7 +28,6 @@ const {
fetchList,
fetchSearch,
fetchTotal,
setActiveFolder,
submitSearch,
goToPage,
removeItems,
@@ -63,14 +52,7 @@ const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false)
const showScrollTop = ref(false)
const currentFolder = 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(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
@@ -82,41 +64,29 @@ const storageSelectItems = computed(() => [
const selectedCount = computed(() => selectedKeys.value.size)
const FOLDER_CHART_COLORS = [
const SOURCE_CHART_COLORS = [
'var(--ui-primary)',
'#10b981',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4'
'#10b981'
]
const folderUploadItems = computed(() =>
(stats.value?.byFolderUploads ?? []).map((item, index) => ({
key: item.folder,
label: item.folder,
count: item.count,
color: FOLDER_CHART_COLORS[index % FOLDER_CHART_COLORS.length]!
}))
const sourceUploadItems = computed(() => {
const bySource = stats.value?.bySource
if (!bySource) return []
return [
{ key: 'web', label: t('stats.sourceWeb'), count: bySource.web, color: SOURCE_CHART_COLORS[0]! },
{ key: 'api', label: t('stats.sourceApi'), count: bySource.api, color: SOURCE_CHART_COLORS[1]! }
]
})
const sourceUploadTotal = computed(() =>
sourceUploadItems.value.reduce((sum, item) => sum + item.count, 0)
)
const donutSegments = computed(() =>
folderUploadItems.value.map(item => ({
label: item.label,
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)
)
function sourcePercent(count: number) {
const total = sourceUploadTotal.value
if (!total) return 0
return Math.round((count / total) * 100)
}
const statCards = computed(() => {
const s = stats.value
@@ -143,17 +113,6 @@ function scrollToTop() {
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() {
stats.value = await $fetch<StatsResponse>('/api/stats', {
credentials: 'include'
@@ -178,7 +137,7 @@ async function refreshGallery() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()])
await Promise.all([refreshList(), loadStorageBackendOptions()])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) {
handleAuthError(error)
@@ -202,7 +161,6 @@ async function refreshAll() {
await Promise.all([
fetchStats(),
reloadGallery(),
loadFolders(),
loadStorageBackendOptions()
])
currentStorage.value = activeStorageBackend.value
@@ -216,17 +174,6 @@ 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) {
if (!isAuthenticated.value) return
currentStorage.value = backendId
@@ -406,67 +353,60 @@ watch(isAuthenticated, async (authed, prev) => {
/>
</div>
<section class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div
v-for="item in statCards"
:key="item.label"
class="rounded-xl border border-default bg-elevated p-4"
>
<p class="text-xs text-muted">
{{ item.label }}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">
{{ item.value }}
</p>
</div>
</section>
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted">
{{ t('stats.folderDistribution') }}
</h2>
<section class="grid gap-3 lg:grid-cols-3 lg:items-stretch">
<div class="grid grid-cols-2 gap-3 lg:col-span-2 lg:grid-cols-4">
<div
v-if="!uploadChartTotal"
class="py-6 text-center text-xs text-muted"
v-for="item in statCards"
:key="item.label"
class="rounded-xl border border-default bg-elevated p-4"
>
{{ t('stats.noData') }}
<p class="text-xs text-muted">
{{ item.label }}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">
{{ item.value }}
</p>
</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') }}
<div class="flex h-full min-h-0 flex-col rounded-xl border border-default bg-elevated p-4 lg:col-span-1">
<h2 class="shrink-0 text-sm font-medium text-muted">
{{ t('stats.sourceDistribution') }}
</h2>
<div
v-if="!folderStats.length"
class="py-6 text-center text-xs text-muted"
v-if="!sourceUploadTotal"
class="flex flex-1 items-center justify-center text-center text-xs text-muted"
>
{{ t('stats.noData') }}
</div>
<ul
v-else
class="space-y-2.5"
class="mt-4 flex min-h-0 flex-1 flex-col"
>
<li
v-for="item in folderStats"
:key="item.folder"
v-for="item in sourceUploadItems"
:key="item.key"
class="flex flex-1 flex-col justify-center gap-2 py-1"
>
<div class="mb-1 flex items-center justify-between gap-2 text-xs">
<span class="font-medium">{{ item.folder }}</span>
<span class="tabular-nums text-muted">
{{ t('stats.folderCount', { count: item.count, size: formatFileSize(item.bytes) }) }}
<div class="flex items-center justify-between gap-2 text-xs">
<span class="flex min-w-0 items-center gap-1.5">
<span
class="size-2 shrink-0 rounded-full"
: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>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div class="h-2 rounded-full bg-muted/40">
<div
class="h-full rounded-full bg-primary/70"
:style="{ width: `${Math.max(folderPercent(item.bytes), item.bytes ? 4 : 0)}%` }"
class="h-full rounded-full transition-[width]"
:style="{
width: `${sourcePercent(item.count)}%`,
backgroundColor: item.color
}"
/>
</div>
</li>
@@ -500,14 +440,6 @@ watch(isAuthenticated, async (authed, prev) => {
>
{{ t('stats.selected', { n: selectedCount }) }}
</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
v-model="currentStorage"
:items="storageSelectItems"
+8 -15
View File
@@ -1,15 +1,10 @@
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
export interface ActivityLogUser {
id: number
username: string
}
export type LogSource = 'web' | 'api'
export interface ActivityLogStorage {
id: string
name: string
type: 'local' | 's3'
type: string
}
export interface ActivityLogItem {
@@ -26,18 +21,16 @@ export interface ActivityLogItem {
createdAt: string
}
export interface ActivityLogSummary {
total: number
upload: number
delete: number
}
export interface ActivityLogListResponse {
items: ActivityLogItem[]
total: number
page: number
pageSize: number
totalPages: number
summary: ActivityLogSummary
users?: ActivityLogUser[]
summary: {
total: number
upload: number
delete: number
}
users?: Array<{ id: number, username: string }>
}
+1
View File
@@ -10,6 +10,7 @@ services:
- "6892:6892"
# 首次访问 Web 引导创建管理员,无需预先配置环境变量
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
# 运维:docker exec pichost reset-password | migrate [--apply]
volumes:
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
- ./data:/data
+5 -1
View File
@@ -2,7 +2,11 @@
set -e
if [ "$1" = "reset-password" ]; then
exec node /app/server/cli/reset-password.mjs
exec node /app/server/cli/reset-password.mjs "${@:2}"
fi
if [ "$1" = "migrate" ]; then
exec node /app/server/cli/migrate-to-single-images.mjs "${@:2}"
fi
exec node /app/.output/server/index.mjs "$@"
+5 -3
View File
@@ -6,7 +6,7 @@
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.0** 主线:本地磁盘 + 多对象存储(R2 / COS / OSS / AWS)、`/storage` 管理页、混合直链 |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.2.0**(开发中)主线:统一 `images/` 存储、多对象存储、[`/storage`](docs/README.md)、[`迁移指南`](migration-to-v1.2.md) |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署场景,配置更聚焦 R2 |
- 需要多种云存储或完整管理界面 → 使用 **main**
@@ -14,7 +14,7 @@
## 截图一览
以下为 **main(v1.1.0)** 界面截图。
以下为 **main** 界面截图。
| 文件 | 说明 |
| ---- | ---- |
@@ -31,10 +31,12 @@
| 文档 | 说明 |
| ---- | ---- |
| [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.en.md](domain-separation.en.md) | Dual-domain deployment (English) |
## 相关页面路由(main / v1.1.0)
## 相关页面路由(main)
| 路由 | 权限 | 说明 |
| ---- | ---- | ---- |
+129
View File
@@ -0,0 +1,129 @@
# 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
@@ -0,0 +1,178 @@
# 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)
+13 -28
View File
@@ -88,7 +88,7 @@
"domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
"domainSeparationProxyExample": "View example",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint",
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "Image URL",
"imageBaseUrlHint": "No trailing slash; public image link prefix",
@@ -179,21 +179,16 @@
"settings": {
"title": "System settings",
"subtitle": "Saved to database; environment variables take precedence",
"uploadAccount": "Upload & accounts",
"featureToggles": "Feature toggles",
"defaultFolder": "Default upload folder",
"defaultFolderHint": "Stored in this browser only; affects your upload target",
"allowRegistration": "Open registration",
"allowRegistrationHint": "Allow new users to register (regular users see only their images)",
"accessControl": "Access control",
"refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction",
"refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "Domains & links",
"domainSettings": "Domain settings",
"storagePathSettings": "Storage paths & direct links",
"domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint",
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "Active: {url}",
"imageBaseUrl": "Image URL",
@@ -202,10 +197,7 @@
"imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "Active: {url}",
"hideFolderInUrl": "Hide folder in image 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",
"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",
"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",
"badgeEnv": ".env",
@@ -317,12 +309,11 @@
"deleteTotal": "Total deleted",
"userCount": "Registered users",
"uploadBytes": "Total upload size",
"folderDistribution": "Uploads by folder",
"folderUsage": "Storage by folder",
"sourceDistribution": "Uploads by source",
"sourceWeb": "Web",
"sourceApi": "API",
"noData": "No data",
"folderCount": "{count} · {size}",
"gallery": "Gallery",
"folder": "Folder",
"filterStorageAll": "All storage",
"storage": "Storage",
"searchPlaceholder": "Search filename",
@@ -336,12 +327,11 @@
"deletePartialFail": "{n} image(s) failed to delete",
"deleteFailed": "Delete failed",
"deletedSingle": "Deleted",
"allFolders": "All",
"counting": "Counting…",
"summarySearch": "{folder} · Search “{q}” · {total} images",
"summaryTotal": "{folder} · {total} images",
"summarySearchStorage": "{folder} · {storage} · Search “{q}” · {total} images",
"summaryTotalStorage": "{folder} · {storage} · {total} images"
"summarySearch": "Search “{q}” · {total} images",
"summaryTotal": "{total} images",
"summarySearchStorage": "{storage} · Search “{q}” · {total} images",
"summaryTotalStorage": "{storage} · {total} images"
},
"logs": {
"pageTitle": "Activity log",
@@ -359,7 +349,6 @@
"actionDelete": "Delete",
"sourceWeb": "Web",
"sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "Time",
"colUser": "User",
"colStorage": "Storage",
@@ -418,15 +407,11 @@
"docs": {
"upload": {
"title": "Upload 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/"
"desc": "Upload one or more images via image field. Also accepts file, files. Stored under images/."
},
"list": {
"title": "List images",
"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"
"desc": "Paginated gallery. limit default 20, max 100."
},
"search": {
"title": "Search images",
@@ -442,7 +427,7 @@
},
"twikoo": {
"title": "Twikoo-compatible upload",
"desc": "Twikoo / EasyImage 2.0 protocol; stored under twikoo/. Fields: image + token."
"desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token."
}
}
},
+13 -28
View File
@@ -88,7 +88,7 @@
"domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
"domainSeparationProxyExample": "查看示例",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
@@ -179,21 +179,16 @@
"settings": {
"title": "系统设置",
"subtitle": "保存后写入数据库;环境变量优先级更高",
"uploadAccount": "上传与账号",
"featureToggles": "功能开关",
"defaultFolder": "默认上传目录",
"defaultFolderHint": "保存在本机浏览器,仅影响您的上传目标目录",
"allowRegistration": "开放用户注册",
"allowRegistrationHint": "允许新用户自行注册(普通用户仅可见自己的图片)",
"accessControl": "访问控制",
"refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制",
"refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "域名与链接",
"domainSettings": "域名配置",
"storagePathSettings": "存储路径与直链",
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "当前生效:{url}",
"imageBaseUrl": "图片域名",
@@ -202,10 +197,7 @@
"imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "当前生效:{url}",
"hideFolderInUrl": "隐藏直链目录前缀",
"hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧链接仍可用",
"hideFolderConfirmTitle": "确认隐藏目录前缀?",
"hideFolderConfirmDesc": "开启后不同目录下若存在相同文件名(如 images/a.webp 与 blog/a.webp),短链接 /a.webp 将优先解析到 images 目录,可能访问到非预期的图片。仅单一上传目录或文件名全局唯一时建议开启。",
"hideFolderConfirmEnable": "仍要开启",
"hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧完整路径仍可用。子路径下若存在同名文件,短链可能冲突",
"storageUseDatePath": "上传目录按年/月分组",
"storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传",
"badgeEnv": ".env",
@@ -317,12 +309,11 @@
"deleteTotal": "累计删除",
"userCount": "注册用户数量",
"uploadBytes": "累计上传体积",
"folderDistribution": "上传目录分布",
"folderUsage": "目录占用",
"sourceDistribution": "上传来源分布",
"sourceWeb": "网页",
"sourceApi": "API",
"noData": "暂无数据",
"folderCount": "{count} 张 · {size}",
"gallery": "图库记录",
"folder": "目录",
"filterStorageAll": "全部存储",
"storage": "存储",
"searchPlaceholder": "搜索文件名",
@@ -336,12 +327,11 @@
"deletePartialFail": "{n} 张图片删除失败",
"deleteFailed": "删除失败",
"deletedSingle": "已删除",
"allFolders": "全部",
"counting": "统计中…",
"summarySearch": "{folder} · 搜索「{q}」· 共 {total} 张",
"summaryTotal": "{folder} · 共 {total} 张",
"summarySearchStorage": "{folder} · {storage} · 搜索「{q}」· 共 {total} 张",
"summaryTotalStorage": "{folder} · {storage} · 共 {total} 张"
"summarySearch": "搜索「{q}」· 共 {total} 张",
"summaryTotal": "共 {total} 张",
"summarySearchStorage": "{storage} · 搜索「{q}」· 共 {total} 张",
"summaryTotalStorage": "{storage} · 共 {total} 张"
},
"logs": {
"pageTitle": "操作日志",
@@ -359,7 +349,6 @@
"actionDelete": "删除",
"sourceWeb": "网页",
"sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "时间",
"colUser": "用户",
"colStorage": "存储",
@@ -418,15 +407,11 @@
"docs": {
"upload": {
"title": "上传图片",
"descAdmin": "上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。",
"descUser": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。",
"curlCustomFolder": "# 上传到自定义目录 blog/"
"desc": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片存入 images/ 目录。"
},
"list": {
"title": "获取图片列表",
"descAdmin": "分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。",
"descUser": "分页列出您的图库,limit 默认 20、最大 100。",
"curlFolderFilter": "# 按目录筛选"
"desc": "分页列出图库,limit 默认 20、最大 100。"
},
"search": {
"title": "搜索图片",
@@ -442,7 +427,7 @@
},
"twikoo": {
"title": "twikoo 兼容上传",
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。"
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。"
}
}
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pichost",
"version": "1.1.5",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pichost",
"version": "1.1.5",
"version": "1.2.0",
"hasInstallScript": true,
"license": "GPL-3.0-only",
"dependencies": {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "pichost",
"version": "1.1.5",
"version": "1.2.0",
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme",
"repository": {
@@ -33,7 +33,8 @@
"test": "vitest run",
"test:watch": "vitest",
"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": {
"@aws-sdk/client-s3": "^3.876.0",
+42 -6
View File
@@ -3,8 +3,9 @@ import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } fr
import { createApiError } from '../../utils/api-error'
import { MAX_DELETE_BATCH } from '../../utils/constants'
import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage } from '../../utils/storage'
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
import { logInfo } from '../../utils/logger'
interface BatchDeleteBody {
@@ -33,7 +34,11 @@ export default defineEventHandler(async (event) => {
}
for (const key of keys) {
if (!validateImageKey(key)) {
if (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
) {
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400)
}
}
@@ -43,7 +48,38 @@ export default defineEventHandler(async (event) => {
for (const key of keys) {
try {
const existing = await headImage(key)
const storageKey = await resolveStorageImageKey(key) ?? 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) {
failed.push({
@@ -63,7 +99,7 @@ export default defineEventHandler(async (event) => {
continue
}
await deleteImage(key)
await deleteImage(storageKey, key)
deleted.push(key)
insertActivityLog({
@@ -77,7 +113,7 @@ export default defineEventHandler(async (event) => {
backendId: existing.backendId ?? null
})
logInfo('delete success', { key, size: existing.size })
logInfo('delete success', { key: storageKey, size: existing.size })
} catch {
failed.push({
key,
+1 -4
View File
@@ -1,5 +1,4 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { createApiError } from '../../utils/api-error'
import { countImages } from '../../utils/storage'
@@ -9,14 +8,12 @@ export default defineEventHandler(async (event) => {
const userFilter = await getImageUserFilter(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)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const total = await countImages(folder, userFilter, backendId)
const total = await countImages(userFilter, backendId)
return { total }
})
+39 -6
View File
@@ -2,8 +2,9 @@ import type { DeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage } from '../../utils/storage'
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
import { logInfo } from '../../utils/logger'
interface DeleteBody {
@@ -19,11 +20,43 @@ export default defineEventHandler(async (event) => {
const key = (typeof query.key === 'string' ? query.key : undefined)
?? body?.key
if (!key || !validateImageKey(key)) {
if (
!key
|| (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
)
) {
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
}
const existing = await headImage(key)
const storageKey = await resolveStorageImageKey(key) ?? 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) {
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
}
@@ -31,7 +64,7 @@ export default defineEventHandler(async (event) => {
await assertImageOwnership(event, existing.userId)
try {
await deleteImage(key)
await deleteImage(storageKey, key)
} catch {
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
}
@@ -47,7 +80,7 @@ export default defineEventHandler(async (event) => {
backendId: existing.backendId ?? null
})
logInfo('delete success', { key, size: existing.size })
logInfo('delete success', { key: storageKey, size: existing.size })
const response: DeleteResponse = { success: true }
return response
+1 -9
View File
@@ -9,16 +9,9 @@ import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
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) => {
await requireApiOrAdminAuth(event)
@@ -34,14 +27,13 @@ export default defineEventHandler(async (event) => {
? Math.floor(pageRaw)
: 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, folder, userFilter, backendId })
const listing = await listImages({ limit, page, userFilter, backendId })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
+1 -9
View File
@@ -9,16 +9,9 @@ import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
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) => {
await requireApiOrAdminAuth(event)
@@ -43,14 +36,13 @@ export default defineEventHandler(async (event) => {
? Math.floor(pageRaw)
: 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, folder, userFilter, backendId })
const result = await searchImages({ query: q, limit, page, userFilter, backendId })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
+1 -46
View File
@@ -1,6 +1,6 @@
import { readMultipartFormData } from 'h3'
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId, canUseCustomUploadFolder } from '../../utils/access'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId } from '../../utils/access'
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
import {
detectMimeFromSignature,
@@ -11,22 +11,8 @@ import {
processSingleImageUpload
} from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error'
import {
DEFAULT_FOLDER,
isValidFolderName,
normalizeFolderName
} from '../../utils/image-key'
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) => {
await requireUploadAuth(event)
@@ -44,36 +30,6 @@ export default defineEventHandler(async (event) => {
const source = verifyApiUploadToken(event) ? 'api' : 'web'
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 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter(
part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
@@ -127,7 +83,6 @@ export default defineEventHandler(async (event) => {
bytes,
filename: part.filename,
source,
prefix: folder || DEFAULT_FOLDER,
userId: uploadUserId
})
+1 -1
View File
@@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
: undefined
const sourceRaw = typeof query.source === 'string' ? query.source : ''
const source = (sourceRaw === 'web' || sourceRaw === 'api' || sourceRaw === 'twikoo')
const source = (sourceRaw === 'web' || sourceRaw === 'api')
? sourceRaw as LogSource
: undefined
+1 -1
View File
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
uploadTotal: scoped.storedCount,
deleteTotal: 0,
uploadBytesTotal: scoped.uploadBytesTotal,
bySource: { web: scoped.storedCount, api: 0, twikoo: 0 },
bySource: { web: scoped.storedCount, api: 0 },
byFolderUploads: scoped.byFolder.map(item => ({
folder: item.folder,
count: item.count
+166
View File
@@ -0,0 +1,166 @@
/**
* 将 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)
})
+1 -1
View File
@@ -1,7 +1,7 @@
import { clientIp, logError, logInfo, logWarn } from '../utils/logger'
/**
* 只记录 /api/*,避免 /images、/twikoo 直链刷屏。
* 只记录 /api/*,避免 /images 直链刷屏。
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', (event) => {
+57 -14
View File
@@ -1,6 +1,6 @@
import { getDataDir } from '../utils/data-dir'
import { runAutoDeleteCleanup } from '../utils/auto-delete'
import { migrateLocalImagesToIndex } from '../utils/image-index'
import { migrateLocalImagesToIndex, purgeOrphanImageIndexRows, reconcileLegacyImageKeys, repairLocalImageIndexMetadata } from '../utils/image-index'
import { ensureDefaultBackends } from '../utils/storage-backends'
import { logException, logInfo } from '../utils/logger'
@@ -16,19 +16,62 @@ export default defineNitroPlugin(() => {
})
ensureDefaultBackends()
void migrateLocalImagesToIndex().then((count) => {
if (count > 0) {
logInfo('migrated local images to index', { count })
}
}).catch((error) => {
logException('migrate local images to index failed', error)
})
void runAutoDeleteCleanup().then((result) => {
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
logInfo('startup auto-delete', { ...result })
void (async () => {
const dataDir = getDataDir()
try {
logInfo('开始同步图片索引:扫描本地磁盘', { path: `${dataDir}/images/` })
const inserted = await migrateLocalImagesToIndex()
if (inserted > 0) {
logInfo('磁盘扫描完成:已补全缺失索引', { inserted })
} else {
logInfo('磁盘扫描完成:索引与磁盘一致,无需补全')
}
logInfo('开始归一化遗留图片路径(如 blog/ → images/blog/)')
const reconciled = await reconcileLegacyImageKeys()
if (reconciled.updated > 0 || reconciled.removed > 0) {
logInfo('遗留路径归一化完成', reconciled)
} else {
logInfo('遗留路径归一化完成:无需调整')
}
logInfo('开始校验索引与磁盘一致性,清理无文件的孤儿记录')
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)
}
}).catch((error) => {
logException('startup auto-delete failed', 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)
}
})()
})
+2 -3
View File
@@ -56,12 +56,11 @@ export default defineEventHandler(async (event) => {
return easyImageError(415, '无法识别的图片格式')
}
// Twikoo 评论图片单独存放在 twikoo/ 目录下
// Twikoo 走 token 上传,记入 activity log source=api
const result = await processSingleImageUpload(event, {
bytes,
filename: imagePart.filename,
prefix: 'twikoo',
source: 'twikoo'
source: 'api'
})
if ('error' in result) {
-18
View File
@@ -236,21 +236,3 @@ export async function getUploadUserId(event: H3Event): Promise<number | null> {
const user = await getCurrentUser(event)
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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,20 @@
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'
}
}
+12 -4
View File
@@ -5,7 +5,7 @@ import { getDataDir } from './data-dir'
import { logException } from './logger'
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
export type LogSource = 'web' | 'api'
export interface ActivityLogRow {
id: number
@@ -53,7 +53,7 @@ export function getDb(): DatabaseSync {
original_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
content_type TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK(source IN ('web', 'api', 'twikoo')),
source TEXT NOT NULL CHECK(source IN ('web', 'api')),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
@@ -142,6 +142,10 @@ function migrateSchema(database: DatabaseSync): void {
if (!activityLogColumns.some(column => column.name === 'backend_id')) {
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'
@@ -615,9 +619,13 @@ function countBySource(): Record<LogSource, number> {
GROUP BY source
`).all() as unknown as Array<{ source: LogSource, total: number }>
const result: Record<LogSource, number> = { web: 0, api: 0, twikoo: 0 }
const result: Record<LogSource, number> = { web: 0, api: 0 }
for (const row of rows) {
result[row.source] = row.total
const raw = row.source as string
const source = raw === 'twikoo' ? 'api' : row.source
if (source === 'web' || source === 'api') {
result[source] += row.total
}
}
return result
}
+5 -2
View File
@@ -4,12 +4,15 @@ import { validateImageKey } from './image-key'
describe('image index key validation', () => {
it('accepts standard image keys used in index', () => {
expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('images/file/111.webp')).toBe(true)
expect(validateImageKey('images/2026/08/28/123.webp')).toBe(true)
expect(validateImageKey('images/abc.webp')).toBe(true)
})
it('rejects path traversal keys', () => {
it('rejects legacy top-level keys and path traversal', () => {
expect(validateImageKey('../secret')).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)
})
})
+152 -81
View File
@@ -1,8 +1,9 @@
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { getDb } from './db'
import { getDataDir } from './data-dir'
import { DEFAULT_FOLDER, isValidFolderName, sortFolderNames, validateImageKey } from './image-key'
import { contentTypeFromKey } from './content-type'
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
import {
ensureDefaultBackends,
@@ -100,6 +101,99 @@ export function deleteImageIndex(key: string): void {
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 {
ensureStorageSchema()
const row = getDb().prepare(`
@@ -129,7 +223,7 @@ export function findImageKeyByDatePath(datePath: string): string | null {
const fallback = `${DEFAULT_FOLDER}/${datePath}`
return validateImageKey(fallback) ? fallback : null
}
if (rows.length === 1) return rows[0]!.key
if (rows.length === 1) return preferIndexedImageKey(rows[0]!.key)
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${datePath}`)
if (exactDefault) return exactDefault.key
@@ -152,7 +246,7 @@ export function findImageKeyByFilename(filename: string): string | null {
const flatFallback = `${DEFAULT_FOLDER}/${filename}`
return validateImageKey(flatFallback) ? flatFallback : null
}
if (rows.length === 1) return rows[0]!.key
if (rows.length === 1) return preferIndexedImageKey(rows[0]!.key)
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${filename}`)
if (exactDefault) return exactDefault.key
@@ -161,57 +255,39 @@ export function findImageKeyByFilename(filename: string): string | null {
return defaultFolder?.key ?? rows[0]!.key
}
async function readDirNames(path: string): Promise<string[]> {
try {
const entries = await fs.readdir(path, { withFileTypes: true })
return entries.filter(e => e.isDirectory()).map(e => e.name)
} catch {
return []
}
}
async function readFileNames(path: string): Promise<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 collectImageKeysRecursive(
dir: string,
keyPrefix: string
): Promise<string[]> {
const keys: string[] = []
const dataDir = getDataDir()
const prefixes = await readDirNames(dataDir)
for (const prefix of prefixes) {
if (!isValidFolderName(prefix)) continue
const root = join(dataDir, prefix)
const 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)
}
try {
const entries = await fs.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
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 {
return keys
}
return keys
}
async function listLocalImageKeys(): Promise<string[]> {
const imagesRoot = join(getDataDir(), DEFAULT_FOLDER)
return collectImageKeysRecursive(imagesRoot, DEFAULT_FOLDER)
}
export async function migrateLocalImagesToIndex(): Promise<number> {
ensureDefaultBackends()
const keys = await listLocalImageKeys()
@@ -231,12 +307,17 @@ export async function migrateLocalImagesToIndex(): Promise<number> {
const meta = await readLocalMeta(key)
const uploadedAt = meta.uploadedAt ?? stat.mtime.toISOString()
const inferredType = contentTypeFromKey(key)
const contentType = meta.contentType && meta.contentType !== 'application/octet-stream'
? meta.contentType
: inferredType
insertImageIndex({
key,
backendId: LOCAL_BACKEND_ID,
userId: meta.userId ?? null,
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
contentType: meta.contentType ?? 'application/octet-stream',
contentType,
size: stat.size,
uploadedAt
})
@@ -247,28 +328,36 @@ export async function migrateLocalImagesToIndex(): Promise<number> {
}
export async function listFolders(): Promise<string[]> {
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)
return [DEFAULT_FOLDER]
}
export async function listFoldersForUser(
userFilter?: number | 'admin'
_userFilter?: number | 'admin'
): Promise<string[]> {
return [DEFAULT_FOLDER]
}
/** 修正存量索引中错误的 application/octet-stream */
export function repairLocalImageIndexMetadata(): number {
ensureStorageSchema()
const params: Array<string | number> = []
const userSql = buildUserFilterSql(userFilter, params)
const rows = getDb().prepare(`
SELECT DISTINCT folder FROM images WHERE 1=1${userSql} ORDER BY folder ASC
`).all(...params) as Array<{ folder: string }>
SELECT key, content_type FROM images WHERE content_type = 'application/octet-stream'
`).all() as Array<{ key: string, content_type: string }>
const folders = rows.map(row => row.folder).filter(isValidFolderName)
return sortFolderNames(folders)
let repaired = 0
const update = getDb().prepare(`
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(
@@ -281,7 +370,6 @@ function buildBackendFilterSql(
}
export async function countImages(
folder?: string,
userFilter?: number | 'admin',
backendId?: string
): Promise<number> {
@@ -289,11 +377,6 @@ export async function countImages(
const params: Array<string | number> = []
const clauses: string[] = ['1=1']
if (folder && isValidFolderName(folder)) {
clauses.push('folder = ?')
params.push(folder)
}
const userSql = buildUserFilterSql(userFilter, params)
const backendSql = buildBackendFilterSql(backendId, params)
const row = getDb().prepare(`
@@ -322,7 +405,6 @@ function paginateSlice<T>(
export async function listImages(options: {
limit: number
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
@@ -330,11 +412,6 @@ export async function listImages(options: {
const params: Array<string | number> = []
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 backendSql = buildBackendFilterSql(options.backendId, params)
const rows = getDb().prepare(`
@@ -355,7 +432,6 @@ export async function searchImages(options: {
query: string
limit: number
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
@@ -364,11 +440,6 @@ export async function searchImages(options: {
const params: Array<string | number> = []
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 backendSql = buildBackendFilterSql(options.backendId, params)
const rows = getDb().prepare(`
+18 -14
View File
@@ -1,31 +1,29 @@
import { describe, expect, it } from 'vitest'
import { generateImageKey, sortFolderNames, validateImageKey } from './image-key'
import { generateImageKey, validateImageKey } from './image-key'
describe('validateImageKey', () => {
it('accepts valid date-layout keys', () => {
expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/2025/01/photo.jpg')).toBe(true)
})
it('accepts valid flat-layout keys', () => {
expect(validateImageKey('images/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/photo.jpg')).toBe(true)
})
it('sorts images first then alphabetically', () => {
expect(sortFolderNames(['blog', 'images', 'twikoo'])).toEqual(['images', 'blog', 'twikoo'])
expect(sortFolderNames(['blog'])).toEqual(['images', 'blog'])
it('accepts migrated nested paths under images/', () => {
expect(validateImageKey('images/file/111.webp')).toBe(true)
expect(validateImageKey('images/2026/08/28/123.webp')).toBe(true)
expect(validateImageKey('images/twikoo/2025/01/photo.jpg')).toBe(true)
})
it('rejects path traversal and malformed keys', () => {
it('rejects non-images top-level and path traversal', () => {
expect(validateImageKey('')).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/a.webp')).toBe(false)
expect(validateImageKey('api/2026/08/a.webp')).toBe(false)
expect(validateImageKey('images/26/8/a.webp')).toBe(false)
expect(validateImageKey('blog/2026/08/a.webp')).toBe(false)
expect(validateImageKey('twikoo/photo.jpg')).toBe(false)
expect(validateImageKey('images/2026/08/a.txt')).toBe(false)
expect(validateImageKey('images/2026/a.webp')).toBe(false)
})
})
@@ -33,14 +31,20 @@ describe('generateImageKey', () => {
const date = new Date('2026-08-15T12:00:00Z')
it('generates date-layout keys by default', () => {
const key = generateImageKey('image/webp', date, 'images')
expect(key).toMatch(/^images\/2026\/08\/[A-Za-z0-9]+\.webp$/)
const key = generateImageKey('image/webp', date)
expect(key).toMatch(/^images\/2026\/08\/[A-Za-z0-9]{12}\.webp$/)
expect(validateImageKey(key)).toBe(true)
})
it('generates flat-layout keys when requested', () => {
const key = generateImageKey('image/webp', date, 'images', 'flat')
expect(key).toMatch(/^images\/[A-Za-z0-9]+\.webp$/)
const key = generateImageKey('image/webp', date, 'flat')
expect(key).toMatch(/^images\/[A-Za-z0-9]{12}\.webp$/)
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)
})
})
+31 -57
View File
@@ -13,20 +13,6 @@ export const DEFAULT_FOLDER = 'images'
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
function generateRandomId(length = 12): string {
@@ -40,51 +26,26 @@ export function mimeToExtension(mime: AllowedMimeType): string {
return EXT_MAP[mime]
}
/** 目录名:字母数字开头,允许 - _,最长 32;不含路径分隔符 */
export function isValidFolderName(name: string): boolean {
if (!name || typeof name !== 'string') return false
if (name.length > 32) return false
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]
/** 路径段:字母数字开头,允许 - _ .,最长 64 */
function isValidPathSegment(segment: string): boolean {
if (!segment || segment.length > 64) return false
if (segment === '.' || segment === '..') return false
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment)
}
export function generateImageKey(
contentType: AllowedMimeType,
date = new Date(),
prefix = DEFAULT_FOLDER,
layout: StorageLayout = 'date'
): string {
const id = generateRandomId(12)
const ext = mimeToExtension(contentType)
if (layout === 'flat') {
return `${prefix}/${id}.${ext}`
return `${DEFAULT_FOLDER}/${id}.${ext}`
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${prefix}/${year}/${month}/${id}.${ext}`
return `${DEFAULT_FOLDER}/${year}/${month}/${id}.${ext}`
}
export function validateImageKey(key: string): boolean {
@@ -96,21 +57,34 @@ export function validateImageKey(key: string): boolean {
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false
const folder = parts[0]!
if (!isValidFolderName(folder)) return false
if (parts[0] !== DEFAULT_FOLDER) return false
const filename = parts[parts.length - 1]!
if (!IMAGE_EXT_PATTERN.test(filename)) return false
if (parts.length === 2) {
return true
for (let i = 1; i < parts.length - 1; i++) {
if (!isValidPathSegment(parts[i]!)) return false
}
if (parts.length === 4) {
const year = parts[1]!
const month = parts[2]!
return /^\d{4}$/.test(year) && /^\d{2}$/.test(month)
}
return false
return true
}
/** 将遗留顶层目录 key(如 blog/foo.webp)映射为 images/blog/foo.webp */
export function toCanonicalImageKey(key: string): string | null {
if (validateImageKey(key)) return key
if (!key || key.startsWith('/') || key.includes('..') || key.includes('//')) return null
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 validateImageKey(canonical) ? canonical : null
}
+1 -1
View File
@@ -13,7 +13,7 @@ describe('toPublicImagePath', () => {
it('returns basename when hideFolder is true', () => {
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('images/blog/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('images/a.webp', true)).toBe('a.webp')
})
})
+1 -7
View File
@@ -62,7 +62,6 @@ export async function processSingleImageUpload(
input: {
bytes: Uint8Array
filename?: string
prefix?: string
source?: LogSource
userId?: number | null
}
@@ -123,12 +122,7 @@ export async function processSingleImageUpload(
const uploadedAt = new Date().toISOString()
const layout = getStorageLayout(event)
const key = generateImageKey(
compressed.mime,
new Date(uploadedAt),
input.prefix ?? 'images',
layout
)
const key = generateImageKey(compressed.mime, new Date(uploadedAt), layout)
let backendId: string
try {
+68 -7
View File
@@ -1,10 +1,15 @@
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 { contentTypeFromKey } from './content-type'
import { getDataDir } from './data-dir'
import {
countImages as countImagesFromIndex,
deleteImageIndex,
getFolderStorageStats as getFolderStorageStatsFromIndex,
getImageIndexAsStored,
getImageIndexRow,
getUserScopedStorageStats as getUserScopedStorageStatsFromIndex,
insertImageIndex,
listFolders as listFoldersFromIndex,
@@ -13,6 +18,7 @@ import {
listImages as listImagesFromIndex,
searchImages as searchImagesFromIndex
} from './image-index'
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
import {
getActiveBackend,
getBackendForKey
@@ -33,6 +39,58 @@ export type {
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(
key: string,
bytes: Uint8Array,
@@ -59,11 +117,17 @@ export async function headImage(key: string): Promise<StoredImage | 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: indexed.contentType,
contentType,
uploadedAt: indexed.uploadedAt,
userId: indexed.userId
}
@@ -90,10 +154,10 @@ export async function createImageStream(
return backend.createStream(key, range)
}
export async function deleteImage(key: string): Promise<void> {
export async function deleteImage(key: string, requestedKey?: string): Promise<void> {
const backend = await getBackendForKey(key)
await backend.delete(key)
deleteImageIndex(key)
deleteImageIndexAliases(key, requestedKey)
}
export async function listFolders(): Promise<string[]> {
@@ -106,11 +170,10 @@ export async function listImageKeys(): Promise<string[]> {
}
export async function countImages(
folder?: string,
userFilter?: number | 'admin',
backendId?: string
): Promise<number> {
return countImagesFromIndex(folder, userFilter, backendId)
return countImagesFromIndex(userFilter, backendId)
}
export async function getFolderStorageStats(
@@ -138,7 +201,6 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
export async function listImages(options: {
limit: number
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
@@ -149,7 +211,6 @@ export async function searchImages(options: {
query: string
limit: number
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
+2 -23
View File
@@ -1,6 +1,7 @@
import { createReadStream, promises as fs } from 'node:fs'
import type { ReadStream } from 'node:fs'
import { dirname, join } from 'node:path'
import { contentTypeFromKey } from '../content-type'
import { getDataDir } from '../data-dir'
import { validateImageKey } from '../image-key'
import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
@@ -11,27 +12,6 @@ function keyToFilePath(key: string): string {
return join(getDataDir(), ...key.split('/'))
}
function contentTypeFromKey(key: string): string {
const ext = key.split('.').pop()?.toLowerCase()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
case 'svg':
return 'image/svg+xml'
case 'ico':
return 'image/x-icon'
default:
return 'application/octet-stream'
}
}
async function readMeta(key: string): Promise<Partial<StoredImageMeta>> {
try {
const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
@@ -57,11 +37,10 @@ export class LocalStorageBackend implements StorageBackend {
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)
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, bytes)
await fs.writeFile(filePath + META_SUFFIX, JSON.stringify(meta))
}
async head(key: string): Promise<StoredImage | null> {
+1 -1
View File
@@ -3,6 +3,6 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['server/**/*.test.ts', 'scripts/**/*.test.ts']
include: ['server/**/*.test.ts']
}
})