13 Commits
Author SHA1 Message Date
O96u 20afda5869 release: v1.2.3 — 修复简短图片链接在按年/月存储下 404
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
开启简短链接时纯文件名直链先经 SQLite 索引反查完整 key,不再误解析为扁平 images/文件名路径。
2026-08-30 12:33:33 +08:00
O96u c9c34515dd release: v1.2.2 — 双域名配置与 Host 隔离修复
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
修复设置页误清空管理域、配置与生效 URL 混淆及第三 Host 绕过隔离;加固 Settings API 与中间件,并更新文档站。
2026-08-30 10:57:26 +08:00
O96u 6c6766221b docs: refresh GitHub About description in metadata and FAQ 2026-08-29 12:04:05 +08:00
O96u 58a8e2082f fix: docs Pages deploy permissions and document GitHub Pages setup
Deploy job failed because Pages source must be GitHub Actions; document
About/description guidance and fix workflow job permissions.
2026-08-29 11:53:20 +08:00
O96u 9b0a3f4944 fix: trigger docs workflow when lockfile changes
docs:build uses npm ci; include package.json and package-lock.json in paths.
2026-08-29 11:50:20 +08:00
O96u e9f1cde06c fix: sync package-lock.json for vitepress devDependency
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
npm ci failed in CI because vitepress was added to package.json without
updating the lockfile.
2026-08-29 11:46:56 +08:00
O96u ff20f97522 release: v1.2.1 — login captcha, docs site, and bug fixes
Add slider captcha for web login, VitePress documentation on GitHub Pages,
and fixes for gallery delete refresh, pagination jump, and copy-format persistence.
2026-08-29 11:42:18 +08:00
O96u 469d3be735 release: v1.2.0 — unified images/ storage and legacy migration
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Consolidate local images under data/images/, add docker migrate CLI,
startup index sync with orphan cleanup, and remove upload folder UI/API.
2026-08-28 17:18:03 +08:00
O96u 3dee0437e6 release: v1.1.5 — gallery preview reliability fixes
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Bump version to 1.1.5. Retry gallery thumbnails after transient failures,
verify storage before serving, and stop caching image 404/403 responses.
2026-08-28 14:25:40 +08:00
O96u f25c89855f fix: gallery preview retries and avoid caching failed image responses
Verify storage files before serving from the index, disable CDN caching
on image 404/403 and host-isolation blocks, and retry gallery thumbnails
after transient load failures.
2026-08-28 14:25:04 +08:00
O96u b0a5fbfca5 release: v1.1.4 — storage path layout and bidirectional domain isolation
Bump version to 1.1.4. Add flat vs date storage paths, basename-only
hide-folder URLs, and block image paths on the site host when domain
separation is active.
2026-08-28 09:59:13 +08:00
O96u 438c1d5529 feat: storage path layout, basename URLs, and bidirectional domain isolation
Add storage_use_date_path for flat vs date-key storage layout and emit
basename-only public URLs when hide-folder is enabled. Block image paths
on the site host when domain separation is active so only the image host
serves files. Update settings UI, i18n, tests, and deployment docs.
2026-08-28 09:56:45 +08:00
O96u Build f7488753ca Update LICENSE 2026-08-27 15:32:31 +08:00
121 changed files with 8676 additions and 1749 deletions

No files matched your search

+5 -1
View File
@@ -14,9 +14,13 @@
# 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置) # 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置)
# SITE_BASE_URL=https://admin.example.com # SITE_BASE_URL=https://admin.example.com
# 隐藏图片直链中的目录前缀(如 images/2026/08/a.webp → 2026/08/a.webp) # 隐藏图片直链中的目录前缀(如 images/2026/08/a.webp → a.webp)
# HIDE_FOLDER_IN_URL=false # HIDE_FOLDER_IN_URL=false
# 上传目录按年/月分组(true/date 默认;false/flat 则保存为 images/xxx.webp)
# STORAGE_USE_DATE_PATH=true
# STORAGE_LAYOUT=date
# 图片直链域名(可选;不填则使用当前请求域名,也可在设置页配置) # 图片直链域名(可选;不填则使用当前请求域名,也可在设置页配置)
# IMAGE_BASE_URL=https://pic.example.com # IMAGE_BASE_URL=https://pic.example.com
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "description": "Lightweight self-hosted image hosting — Docker, multi-user, gallery & API, local disk or S3-compatible storage.",
"homepage": "https://github.com/O96u/PicHost#readme", "homepage": "https://o96u.github.io/PicHost/",
"topics": [ "topics": [
"docker", "docker",
"image-hosting", "image-hosting",
+3
View File
@@ -37,3 +37,6 @@ jobs:
- name: Test - name: Test
run: npm run test run: npm run test
- name: Build docs
run: npm run docs:build
+68
View File
@@ -0,0 +1,68 @@
name: docs
on:
push:
branches: [main]
paths:
- 'docs-site/**'
- 'docs/screenshots/**'
- '.github/workflows/docs.yml'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install npm 11
run: |
npm install -g npm@11.12.1
npm --version
- name: Install dependencies
run: npm ci
- name: Build docs
run: npm run docs:build
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: docs-site/.vitepress/dist
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+5
View File
@@ -40,3 +40,8 @@ git-hooks/
# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库) # 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库)
scripts/ scripts/
dist-fpk/ dist-fpk/
# VitePress docs site
docs-site/.vitepress/cache
docs-site/.vitepress/dist
docs-site/public/screenshots
+91 -3
View File
@@ -2,6 +2,93 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [Unreleased]
## [1.2.3] - 2026-08-30
### 修复
- 开启「简短图片链接」且按年/月分组时,纯文件名直链(如 `/xxx.webp`)误解析为 `images/xxx.webp` 导致 404;现先经 SQLite 索引反查完整 key
## [1.2.2] - 2026-08-30
### 修复
- 双域名:设置页保存时误清空管理域(`site_base_url`);仅改图片域时保护已有网站域配置
- 双域名:设置 API 配置值与 request origin fallback 混淆,导致「当前生效」显示错误
- 双域名:未配置的第三 Host(`pages.dev`、裸 IP 等)可绕过 Host 隔离访问后台
### 改进
- 设置 API 拆分 `siteBaseUrl` / `imageBaseUrl`(配置值)、`effective*`(直链)、`runtime`(当前访问检测)
- `PATCH /api/settings` 增加 `domainSeparation` 字段与保存校验
- 设置页:检测地址展示、关闭双域名确认、反代 Host 不一致警告;首次设置不再自动填入 origin
- Host 中间件:双域名开启时对未知 Host 返回 404(开发 `localhost` 例外)
- 文档站:新增 [更新日志](https://o96u.github.io/PicHost/guide/changelog);重写 Cloudflare 部署说明;同步 FAQ / 双域名文档
## [1.2.1] - 2026-08-29
### 新增
- 登录滑动拼图验证(缺口对齐);验证与登录分两步,登录接口校验 `captchaId` / `captchaPosition`
- VitePress 文档站(`docs-site/`):中英文指南,部署至 GitHub Pages <https://o96u.github.io/PicHost/>
- `npm run docs:dev` / `docs:build` / `docs:preview`;CI 增加 `docs:build`;`.github/workflows/docs.yml` 发布文档
### 修复
- 修复统计页删除图片后图库不刷新的问题
- 修复分页跳转输入框在 `type="number"` 下无法跳转的问题
- 修复非首页路由下复制链接格式偏好不持久化的问题(`useUploadPreferences` 首次使用时自动加载)
- 滑动验证 UI:滑块垂直居中、缺口尺寸与滑块一致
### 改进
- README 精简为产品宣传页,详细说明指向文档站
- 双域名文档补充:未知 Host 风险、Cloudflare 橙云部署、勿用 Pages/Workers 整站反代
- 文档截图更新(登录页等);旧 `docs/*.md` 长文迁入 `docs-site/`
## [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、清理孤儿记录),并打印同步日志
- 迁移文档:[文档站 · v1.2 迁移](https://o96u.github.io/PicHost/guide/migration)
### 改进
- 启动时归一化遗留 SQLite key、清理无文件的孤儿索引;支持删除仅索引无文件的记录
## [1.1.5] - 2026-08-28
### 修复
- 修复 API 上传后图库预览偶发失败、需等待刷新才显示的问题
- 图库卡片加载失败后自动重试,刷新列表时重置预览状态
### 改进
- 出图前校验存储文件实际存在,避免索引与磁盘短暂不一致
- 图片 404/403 与域名隔离 404 禁止 CDN 缓存,降低反代误判影响
## [1.1.4] - 2026-08-28
### 新增
- 上传目录可按年/月分组或扁平保存(`storage_use_date_path` / `STORAGE_USE_DATE_PATH`),设置页可切换
- 双域名分离下网站域禁止直链出图,仅图片域可访问图片路径
### 改进
- 隐藏 `images/` 前缀时外链改为仅文件名(如 `xxx.webp`);图片域仍兼容旧版 `/2026/08/xxx.webp` 短链
- 设置页重构:功能开关、域名配置、存储路径分组展示
- 本地磁盘索引扫描支持扁平路径;文件夹列表 `images` 置顶
## [1.1.3] - 2026-08-27 ## [1.1.3] - 2026-08-27
### 新增 ### 新增
@@ -10,7 +97,7 @@
- `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域 - `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域
- 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示) - 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示)
- 设置页加载时检测 GitHub 最新版本并提示更新 - 设置页加载时检测 GitHub 最新版本并提示更新
- 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):`docs/domain-separation.md` - 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):[文档站 · 双域名分离](https://o96u.github.io/PicHost/guide/domain-separation)
### 改进 ### 改进
@@ -98,6 +185,7 @@
- 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单 - 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单
- 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户) - 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户)
- Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`) - Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`)
- Docker 升级到 v1.2.0 前迁移遗留目录:`docker exec pichost migrate` / `migrate --apply`
### 改进 ### 改进
@@ -134,9 +222,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.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1
[1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0 [1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0
[1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4 [1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4
+1
View File
@@ -24,6 +24,7 @@ ENV NODE_ENV=production \
COPY --from=build /app/.output ./.output COPY --from=build /app/.output ./.output
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
COPY server/cli/migrate-to-single-images.mjs ./server/cli/migrate-to-single-images.mjs
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh
-16
View File
@@ -1,19 +1,3 @@
PicHost - Self-hosted image hosting
Copyright (C) 2026 O96u
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License version 3 as
published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
================================================================================
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 29 June 2007
+22 -193
View File
@@ -17,64 +17,38 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#branches">Branches</a> · <a href="https://o96u.github.io/PicHost/en/">Docs</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#screenshots">Screenshots</a> · <a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> · <a href="#features">Features</a> ·
<a href="#tech-stack">Tech Stack</a> · <a href="#quick-start">Quick Start</a> ·
<a href="#roadmap">Roadmap</a> ·
<a href="README.md">简体中文</a> <a href="README.md">简体中文</a>
</p> </p>
--- ---
## Branches
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.3** — local disk + multi-backend storage, dual-domain separation, [`/storage`](docs/README.md) UI, hybrid URLs |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively |
Use **main** for the full product and multiple cloud backends.
If you **only use Cloudflare R2**, check out the dedicated branch:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## Screenshots ## Screenshots
| Upload | Storage | **main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc)
| :------------------------------------: | :--------------------------------: |
| ![Upload](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) |
| Stats & gallery | Settings | | Home | Storage |
| :----------------------------------: | :--------------------------------: | | :--: | :-----: |
| ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings.png) | | ![Home](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) |
| Stats | Settings |
| :---: | :-------: |
| ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings-en.png) |
## Features ## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths - **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL - **Multi-user** — login with slider captcha, optional registration; users see only their images
- **Multi-user** — username/password login; optional registration; users see only their images - **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs
- **Upload preferences** (home card flip): client-side compression, auto-copy links, per-user auto-delete - **Gallery & stats** — browse, search, batch delete; upload trends and source breakdown
- **Multi-backend storage**: local disk + S3-compatible (Cloudflare R2 / Tencent COS / Alibaba OSS / AWS S3); admins manage backends at **Storage** (`/storage`) - **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
- **Hybrid URLs**: `proxy` (same-origin via PicHost) or `public` (302 to CDN / bucket URL) - **Zero-config Docker** — first-run web wizard, no secrets upfront
- **Gallery** — browse, search, batch delete; admin sees uploader badges
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count
- **API** — global token (admin) + per-user tokens; in-app cURL snippets
- **Twikoo** — `POST /api/index.php` (`image` + `token`)
- **Zero-config Docker** — first-run web wizard, no secrets required upfront
## Quick Start ## Quick Start
### Docker (recommended)
```bash ```bash
docker run -d \ docker run -d \
--name pichost \ --name pichost \
@@ -84,159 +58,14 @@ docker run -d \
muxui/pichost:latest muxui/pichost:latest
``` ```
Default port: **6892**. Open `http://<host>:6892` in your browser and complete the setup wizard. Open `http://<host>:6892` and complete the setup wizard.
**Forgot password** (requires `docker exec` / server access): **Full guide** (env vars, upgrades, dual-domain setup, API, Twikoo, reverse proxy, local dev, etc.): **[Documentation](https://o96u.github.io/PicHost/en/)**.
```bash | Branch | Notes |
docker exec pichost reset-password # admin only, when there is exactly one | ------ | ----- |
docker exec pichost reset-password <username> # admin or regular user | [**main**](https://github.com/O96u/PicHost/tree/main) | Default: multi-backend, dual-domain, unified `images/` storage |
``` | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | Cloudflare R2 only; live demo [pic.roven.cc](https://pic.roven.cc) |
A random password is printed. Fails if the user does not exist.
If you cloned the repo, you can also use `docker compose up -d` (see `docker-compose.yml`).
### Local development
```bash
npm install
cp .env.example .env # optional
npm run dev
```
Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`.
## Tech Stack
| Layer | Technology |
| -------- | --------------------------------------------------- |
| Frontend | Nuxt 4, Vue 3, Nuxt UI 4, Tailwind CSS 4 |
| Backend | Nitro (Node.js), SQLite |
| Images | sharp (WebP transcoding & compression) |
| Auth | Session cookie, scrypt password hashing, API tokens |
| Storage | Local disk + S3-compatible backends (R2 / COS / OSS / AWS); `images` index in SQLite |
| Deploy | Docker (amd64 / arm64), docker compose |
| Tests | Vitest |
## Users & permissions
| Scenario | Auth | Ownership / visibility |
| ------------------------------ | -------------------- | --------------------------- |
| Web upload | Session | Current user |
| API upload + **user token** | `Auth-Token` | Token owner |
| API upload + **global token** | `Auth-Token` | Admin (`userId` null) |
| Twikoo `/api/index.php` | form `token` | Same as global token |
| 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.
## 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
```
Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. With cloud backends, blobs live in the bucket; SQLite `images` tracks `backend_id` and `key`. Back up all of `/data` plus bucket data.
## API overview
| Method | Path | Description |
| ------ | -------------------- | ---------------------- |
| POST | `/api/images/upload` | Upload image |
| GET | `/api/images` | Paginated list |
| DELETE | `/api/images` | Delete one (`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
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"
```
Full docs and copyable cURL snippets are on the **API** page after deployment.
## Twikoo
| Setting | Value |
| ----------------- | ----------------------------------- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://your-domain/api/index.php` |
| `IMAGE_CDN_TOKEN` | Same as `API_UPLOAD_TOKEN` |
## Reverse proxy
- Proxy to port **6892**; HTTPS recommended
- Nginx: `client_max_body_size` ≥ 12m
- Set `IMAGE_BASE_URL` (single domain) or `SITE_BASE_URL` + `IMAGE_BASE_URL` (dual-domain separation)
### Recommended architecture (dual domains)
**One Docker instance + two domains**, both fully proxied to `6892` (isolation via PicHost middleware):
```nginx
# admin.example.com — admin + API + images
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — same full proxy; PicHost middleware blocks non-image paths
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
FN NAS [Lucky](https://github.com/gdy666/lucky) reverse proxy (public hostname → backend `http://<LAN-IP>:6892`):
| Site domain (admin) | Image domain |
| :-----------------: | :----------: |
| ![Lucky site](docs/screenshots/lucky-site.png) | ![Lucky image](docs/screenshots/lucky-image.png) |
See **[docs/domain-separation.en.md](docs/domain-separation.en.md)** for Caddy/NPM notes, env vars, and setup fields.
## Development
```bash
npm run dev # Dev server
npm run build # Production build
npm run start # Run .output
npm run lint # ESLint
npm run typecheck # Type check
npm test # Unit tests
```
## Roadmap
| Version | Plan |
| ---------- | -------------------------------------------------------------- |
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check (current `main`) |
| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits |
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
| **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration |
| **v1.0.3** | CI and Docker multi-arch build fixes |
| **v1.0.2** | i18n, mobile menus, stats/upload UI, Docker password reset |
| **v1.0.1** | Settings version display, mobile upload preferences layout fix |
| **v1.0.0** | Local storage, multi-user, API, Twikoo |
## License ## License
+21 -197
View File
@@ -17,64 +17,38 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#分支">分支</a> · <a href="https://o96u.github.io/PicHost/">文档</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#界面预览">预览</a> · <a href="#界面预览">预览</a> ·
<a href="#特性">特性</a> · <a href="#特性">特性</a> ·
<a href="#技术栈">技术栈</a> · <a href="#快速开始">快速开始</a> ·
<a href="#路线图">路线图</a> ·
<a href="README.en.md">English</a> <a href="README.en.md">English</a>
</p> </p>
--- ---
## 分支
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.3** 主线:本地磁盘 + 多对象存储、双域名分离、[`/storage`](docs/README.md) 存储管理、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
日常使用、需要管理多种存储后端,请直接用 **main**。
若图床**只接 Cloudflare R2**、希望更简化的 R2 专用版本,可切换分支:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## 界面预览 ## 界面预览
| 上传 | 存储管理 | **main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
| :-----------------------------------: | :-------------------------------------: |
| ![上传](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) |
| 统计与图库 | 系统设置 | | 首页 | 存储 |
| :---------------------------------: | :-------------------------------------: | | :--: | :--: |
| ![首页](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) |
| 统计 | 设置 |
| :--: | :--: |
| ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) | | ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) |
## 特性 ## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录 - **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名 - **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片 - **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除 - **图库与统计**:浏览、搜索、批量删除;上传趋势与来源分布
- **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端 - **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
- **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址) - **Docker 零配置**:首次访问 Web 引导创建管理员
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
- **Twikoo**:`POST /api/index.php`(`image` + `token`)
- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥
## 快速开始 ## 快速开始
### Docker(推荐)
```bash ```bash
docker run -d \ docker run -d \
--name pichost \ --name pichost \
@@ -84,164 +58,14 @@ docker run -d \
muxui/pichost:latest muxui/pichost:latest
``` ```
默认端口 **6892**。浏览器打开 `http://<主机IP>:6892`,按引导创建管理员即可。 浏览器打开 `http://<主机IP>:6892`,按引导完成设置即可。
**忘记密码**(需能执行 `docker exec`,相当于服务器权限): **详细说明**(环境变量、升级迁移、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。
```bash | 分支 | 说明 |
# 无参数:重置管理员(仅当系统中只有一个管理员时) | ---- | ---- |
docker exec pichost reset-password | [**main**](https://github.com/O96u/PicHost/tree/main) | 默认主线:多后端、双域名、统一 `images/` 存储 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | 仅 Cloudflare R2;在线演示 [pic.roven.cc](https://pic.roven.cc) |
# 指定用户名:可重置管理员或普通用户
docker exec pichost reset-password 用户名
```
终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。
本地:`npm run reset-password` 或 `npm run reset-password -- 用户名`
已克隆仓库时也可用 `docker compose up -d`(见仓库内 `docker-compose.yml`)。
### 本地开发
```bash
npm install
cp .env.example .env # 可选
npm run dev
```
访问 `http://localhost:3000/setup` 创建管理员;数据目录默认 `./data`。
## 技术栈
| 层级 | 技术 |
| -------- | ------------------------------------------ |
| 前端 | Nuxt 4、Vue 3、Nuxt UI 4、Tailwind CSS 4 |
| 后端 | Nitro(Node.js)、SQLite |
| 图片处理 | sharp(WebP 转码与压缩) |
| 鉴权 | Session Cookie、scrypt 密码哈希、API Token |
| 存储 | 本地磁盘 + S3 兼容后端(R2 / COS / OSS / AWS);`images` 索引在 SQLite |
| 部署 | Docker(amd64 / arm64)、docker compose |
| 测试 | Vitest |
## 用户与权限
| 场景 | 鉴权 | 图片归属 / 可见范围 |
| -------------------------- | -------------------- | -------------------------- |
| 网页上传 | Session | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` | 该 Token 所属用户 |
| API 上传 + **全局 Token** | `Auth-Token` | 管理员(`userId` 为空) |
| Twikoo `/api/index.php` | 表单 `token` | 与全局 Token 相同 |
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
普通用户上传目录固定为 `images/`;自定义 `folder` 仅管理员或全局 Token 可用。
## 存储结构
```
/data
├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
├── images/ # 默认上传目录(本地后端时)
├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片
```
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。云存储时文件在对应桶内,SQLite `images` 表记录 `backend_id` 与 `key`。备份请包含整个 `/data` 与云桶数据。
## API 概览
| 方法 | 路径 | 说明 |
| ------ | -------------------- | ---------------------- |
| POST | `/api/images/upload` | 上传图片 |
| GET | `/api/images` | 分页列表 |
| DELETE | `/api/images` | 删除单张(`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
上传示例:
```bash
curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png"
```
完整接口与可复制 cURL 见部署后后台 **API** 页。
## Twikoo
| 配置项 | 值 |
| ----------------- | -------------------------------- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://你的域名/api/index.php` |
| `IMAGE_CDN_TOKEN` | 与 `API_UPLOAD_TOKEN` 相同 |
## 反向代理
- 反代到容器 **6892** 端口,建议 HTTPS
- Nginx:`client_max_body_size` ≥ 12m
- 公网访问请配置 `IMAGE_BASE_URL`(单域名)或 `SITE_BASE_URL` + `IMAGE_BASE_URL`(双域名分离)
### 推荐部署架构(双域名分离)
**单 Docker 实例 + 两个域名** 均可全量反代到 `6892`(隔离由 PicHost 中间件负责):
```nginx
# admin.example.com — 后台 + API + 出图
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
飞牛 NAS [Lucky](https://github.com/gdy666/lucky) 反向代理示例(前端域名 → 后端 `http://<内网IP>:6892`):
| 网站域名(管理后台) | 图片域名 |
| :------------------: | :------: |
| ![Lucky 网站域名](docs/screenshots/lucky-site.png) | ![Lucky 图片域名](docs/screenshots/lucky-image.png) |
完整说明(Caddy / NPM、环境变量、初始化填写项)见 **[docs/domain-separation.md](docs/domain-separation.md)**。
## 开发
```bash
npm run dev # 开发服务器
npm run build # 生产构建
npm run start # 运行 .output
npm run lint # ESLint
npm run typecheck # 类型检查
npm test # 单元测试
```
## 路线图
| 版本 | 说明 |
| ---------- | ------------------------------------------------- |
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示(当前 main) |
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
| **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 |
| **v1.0.3** | CI 与 Docker 多架构构建修复 |
| **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复 |
| **v1.0.0** | 本地存储、多用户、API、Twikoo |
## License ## License
+27 -4
View File
@@ -11,14 +11,29 @@ const username = ref('')
const password = ref('') const password = ref('')
const secret = ref('') const secret = ref('')
const loading = ref(false) const loading = ref(false)
const captchaRef = ref<{ reset: () => Promise<void> } | null>(null)
const captchaPayload = ref<{ captchaId: string, captchaPosition: number } | null>(null)
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false) const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false) const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
const canSubmit = computed(() => const canSubmit = computed(() =>
legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value) Boolean(captchaPayload.value)
&& (legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value))
) )
function onCaptchaVerified(payload: { id: string, positionPercent: number }) {
captchaPayload.value = {
captchaId: payload.id,
captchaPosition: payload.positionPercent
}
}
async function resetCaptcha() {
captchaPayload.value = null
await captchaRef.value?.reset()
}
onMounted(async () => { onMounted(async () => {
const status = await fetchStatus() const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) { if (!status.initialized && !status.legacyMode) {
@@ -27,13 +42,14 @@ onMounted(async () => {
}) })
async function submit() { async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value || !captchaPayload.value) return
loading.value = true loading.value = true
try { try {
const captcha = captchaPayload.value
const result = legacyMode.value const result = legacyMode.value
? await login({ secret: secret.value }) ? await login({ secret: secret.value }, captcha)
: await login({ username: username.value.trim(), password: password.value }) : await login({ username: username.value.trim(), password: password.value }, captcha)
if (result.ok) { if (result.ok) {
if (result.needsMigration) { if (result.needsMigration) {
@@ -43,8 +59,10 @@ async function submit() {
username.value = '' username.value = ''
password.value = '' password.value = ''
secret.value = '' secret.value = ''
await resetCaptcha()
toast.add({ title: t('auth.loginSuccess'), color: 'success' }) toast.add({ title: t('auth.loginSuccess'), color: 'success' })
} else { } else {
await resetCaptcha()
toast.add({ toast.add({
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')), title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
color: 'error' color: 'error'
@@ -152,6 +170,11 @@ async function submit() {
</div> </div>
</template> </template>
<SliderCaptcha
ref="captchaRef"
@verified="onCaptchaVerified"
/>
<UButton <UButton
type="submit" type="submit"
:label="t('auth.login')" :label="t('auth.login')"
+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>
+50 -6
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ImageItem } from '~/types/image' import type { ImageItem } from '~/types/image'
import type { CopyFormat } from '~/composables/useUploadPreferences'
type CopyFormat = 'url' | 'markdown' | 'html'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
image: ImageItem image: ImageItem
@@ -25,8 +24,52 @@ const emit = defineEmits<{
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const { copyFormat } = useUploadPreferences()
const imageError = ref(false) const imageError = ref(false)
const copyFormat = ref<CopyFormat>('url') const retryCount = ref(0)
function selectCopyFormat(value: CopyFormat) {
copyFormat.value = value
}
const MAX_PREVIEW_RETRIES = 3
const PREVIEW_RETRY_DELAYS_MS = [1000, 2000, 4000]
const previewSrc = computed(() => {
if (retryCount.value === 0) return props.image.url
const separator = props.image.url.includes('?') ? '&' : '?'
return `${props.image.url}${separator}retry=${retryCount.value}`
})
watch(() => props.image.key, () => {
imageError.value = false
retryCount.value = 0
})
let retryTimer: ReturnType<typeof setTimeout> | undefined
function clearRetryTimer() {
if (retryTimer !== undefined) {
clearTimeout(retryTimer)
retryTimer = undefined
}
}
function onPreviewError() {
if (retryCount.value >= MAX_PREVIEW_RETRIES) {
imageError.value = true
return
}
const delay = PREVIEW_RETRY_DELAYS_MS[retryCount.value] ?? PREVIEW_RETRY_DELAYS_MS.at(-1)!
clearRetryTimer()
retryTimer = setTimeout(() => {
retryCount.value += 1
retryTimer = undefined
}, delay)
}
onUnmounted(clearRetryTimer)
const copyFormatItems = computed(() => [ const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const }, { label: t('copy.url'), value: 'url' as const },
@@ -78,11 +121,12 @@ function toggleSelected(value: boolean | 'indeterminate') {
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted"> <div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
<img <img
v-if="!imageError" v-if="!imageError"
:src="image.url" :key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName" :alt="image.originalName"
loading="lazy" loading="lazy"
class="absolute inset-0 size-full object-cover object-center" class="absolute inset-0 size-full object-cover object-center"
@error="imageError = true" @error="onPreviewError"
> >
<div <div
v-else v-else
@@ -190,7 +234,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
:variant="copyFormat === item.value ? 'solid' : 'ghost'" :variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'" :color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label" :label="item.label"
@click="() => { copyFormat = item.value }" @click="selectCopyFormat(item.value)"
/> />
</div> </div>
+44 -2
View File
@@ -14,13 +14,30 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const unitLabel = computed(() => props.unit ?? t('common.itemsUnit')) const unitLabel = computed(() => props.unit ?? t('common.itemsUnit'))
const jumpInput = ref('')
function goTo(target: number) { function goTo(target: number) {
if (target < 1 || target > props.totalPages || target === props.page || props.loading) { if (target < 1 || target > props.totalPages || props.loading) {
return
}
if (target === props.page) {
jumpInput.value = ''
return return
} }
emit('update:page', target) emit('update:page', target)
} }
function submitJump() {
const raw = String(jumpInput.value ?? '').trim()
if (!raw) return
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed)) return
goTo(parsed)
}
watch(() => props.page, () => {
jumpInput.value = ''
})
</script> </script>
<template> <template>
@@ -43,7 +60,7 @@ function goTo(target: number) {
<div <div
v-if="totalPages > 1" v-if="totalPages > 1"
class="flex items-center gap-1" class="flex flex-wrap items-center justify-center gap-1"
> >
<UButton <UButton
icon="i-lucide-chevrons-left" icon="i-lucide-chevrons-left"
@@ -77,6 +94,31 @@ function goTo(target: number) {
:disabled="page >= totalPages || loading" :disabled="page >= totalPages || loading"
@click="goTo(totalPages)" @click="goTo(totalPages)"
/> />
<form
class="ml-1 flex items-center gap-1"
@submit.prevent="submitJump"
>
<UInput
v-model="jumpInput"
type="number"
:min="1"
:max="totalPages"
size="sm"
class="w-16"
:placeholder="t('pagination.pagePlaceholder')"
:disabled="loading"
:aria-label="t('pagination.pagePlaceholder')"
@keyup.enter="submitJump"
/>
<UButton
type="button"
size="sm"
variant="outline"
:label="t('pagination.goTo')"
:disabled="loading || !String(jumpInput ?? '').trim()"
@click="submitJump"
/>
</form>
</div> </div>
</div> </div>
</template> </template>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
const emit = defineEmits<{
verified: [payload: { id: string, positionPercent: number }]
}>()
const { t } = useI18n()
const THUMB_SIZE = 40
const TRACK_INSET = 4
const trackRef = ref<HTMLElement | null>(null)
const challengeId = ref('')
const trackSvg = ref('')
const sliderPercent = ref(0)
const verified = ref(false)
const loading = ref(false)
const dragging = ref(false)
const shake = ref(false)
const confirming = ref(false)
const trackWidth = ref(0)
function updateTrackWidth() {
trackWidth.value = trackRef.value?.clientWidth ?? 0
}
function percentToPx(percent: number) {
const max = Math.max(trackWidth.value - THUMB_SIZE - TRACK_INSET * 2, 0)
return TRACK_INSET + (percent / 100) * max
}
const thumbLeft = computed(() => percentToPx(sliderPercent.value))
const isActive = computed(() => dragging.value || sliderPercent.value > 0.5)
const fillWidth = computed(() => `${Math.max(thumbLeft.value + THUMB_SIZE - TRACK_INSET, 0)}px`)
const trackSvgUrl = computed(() =>
trackSvg.value
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(trackSvg.value)}`
: ''
)
const hintOpacity = computed(() => {
if (verified.value) return 0
if (!isActive.value) return 1
return Math.max(0, 1 - sliderPercent.value / 40)
})
const successLabelLeft = computed(() => `${thumbLeft.value + THUMB_SIZE + 8}px`)
async function loadChallenge() {
loading.value = true
verified.value = false
dragging.value = false
shake.value = false
sliderPercent.value = 0
trackSvg.value = ''
try {
const data = await $fetch<{ id: string, svg: string }>('/api/auth/captcha')
challengeId.value = data.id
trackSvg.value = data.svg
} finally {
loading.value = false
await nextTick()
updateTrackWidth()
}
}
function setSliderFromClientX(clientX: number) {
if (!trackRef.value || verified.value || loading.value || confirming.value) return
const rect = trackRef.value.getBoundingClientRect()
const max = Math.max(rect.width - THUMB_SIZE - TRACK_INSET * 2, 1)
const x = Math.min(
Math.max(clientX - rect.left - THUMB_SIZE / 2 - TRACK_INSET, 0),
max
)
sliderPercent.value = (x / max) * 100
}
async function tryConfirm() {
if (verified.value || !challengeId.value || confirming.value) return
confirming.value = true
try {
const position = Math.round(sliderPercent.value)
await $fetch('/api/auth/captcha/confirm', {
method: 'POST',
body: {
id: challengeId.value,
captchaPosition: position
}
})
verified.value = true
emit('verified', {
id: challengeId.value,
positionPercent: position
})
} catch {
shake.value = true
sliderPercent.value = 0
window.setTimeout(() => {
shake.value = false
}, 450)
} finally {
confirming.value = false
}
}
function onThumbPointerDown(event: PointerEvent) {
if (verified.value || loading.value || confirming.value) return
dragging.value = true
updateTrackWidth()
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onThumbPointerMove(event: PointerEvent) {
if (!dragging.value) return
setSliderFromClientX(event.clientX)
}
function onThumbPointerUp() {
if (!dragging.value) return
dragging.value = false
if (!verified.value) {
void tryConfirm()
}
}
onMounted(() => {
void loadChallenge()
})
let resizeObserver: ResizeObserver | undefined
watch(trackRef, (el, _, onCleanup) => {
resizeObserver?.disconnect()
resizeObserver = undefined
if (!el) return
updateTrackWidth()
if (typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(updateTrackWidth)
resizeObserver.observe(el)
onCleanup(() => {
resizeObserver?.disconnect()
resizeObserver = undefined
})
})
defineExpose({ reset: loadChallenge })
</script>
<template>
<div
ref="trackRef"
class="captcha-track relative h-12 select-none overflow-hidden rounded-lg border transition-[background-color,border-color] duration-300"
:class="[
verified
? 'border-primary/35 bg-primary/10'
: 'border-default bg-muted/20',
loading ? 'opacity-70' : '',
shake ? 'captcha-shake' : ''
]"
>
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<UIcon
name="i-lucide-loader-2"
class="size-5 animate-spin text-muted"
/>
</div>
<template v-else>
<img
v-if="trackSvgUrl"
:src="trackSvgUrl"
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full object-fill transition-opacity duration-300"
:class="verified ? 'opacity-0' : 'opacity-100'"
draggable="false"
>
<div
v-if="isActive && !verified"
class="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-primary/20 transition-[width] duration-75"
:style="{ width: fillWidth }"
/>
<p
v-if="!verified"
class="pointer-events-none absolute inset-0 flex items-center justify-center pl-12 text-sm text-muted transition-opacity duration-150"
:style="{ opacity: hintOpacity }"
>
{{ t('auth.captchaHint') }}
</p>
<p
v-if="verified"
class="pointer-events-none absolute inset-y-0 flex items-center pr-3 text-sm font-medium text-primary"
:style="{ left: successLabelLeft }"
>
{{ t('auth.captchaSuccess') }}
</p>
<div
class="absolute top-1/2 -translate-y-1/2 touch-none rounded-md border transition-[left,background-color,border-color,color,box-shadow,transform] duration-150"
:class="[
verified
? 'z-20 border-primary bg-primary text-white shadow-sm shadow-primary/25'
: isActive
? 'z-10 border-primary/45 bg-elevated text-primary shadow-md'
: 'z-10 border-default/90 bg-elevated text-muted shadow-sm',
dragging ? 'scale-[1.02] cursor-grabbing' : verified ? 'cursor-default' : 'cursor-grab'
]"
:style="{
left: `${thumbLeft}px`,
width: `${THUMB_SIZE}px`,
height: `${THUMB_SIZE}px`
}"
@pointerdown="onThumbPointerDown"
@pointermove="onThumbPointerMove"
@pointerup="onThumbPointerUp"
@pointercancel="onThumbPointerUp"
>
<div class="flex size-full items-center justify-center">
<UIcon
v-if="confirming"
name="i-lucide-loader-2"
class="size-4 animate-spin"
:class="verified ? 'text-white' : isActive ? 'text-primary' : 'text-muted'"
/>
<UIcon
v-else
:name="verified ? 'i-lucide-check' : 'i-lucide-chevrons-right'"
class="size-4"
/>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.captcha-shake {
animation: captcha-shake 0.4s ease;
}
@keyframes captcha-shake {
0%,
100% {
transform: translateX(0);
}
25%,
75% {
transform: translateX(-4px);
}
50% {
transform: translateX(4px);
}
}
</style>
-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>
+12 -2
View File
@@ -66,6 +66,11 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string {
export type AuthActionResult = { ok: true } | { ok: false, error: string } export type AuthActionResult = { ok: true } | { ok: false, error: string }
export interface LoginCaptchaPayload {
captchaId: string
captchaPosition: number
}
export function useAuth() { export function useAuth() {
const { t } = useI18n() const { t } = useI18n()
const status = useState<AuthStatus>('auth-status', () => 'checking') const status = useState<AuthStatus>('auth-status', () => 'checking')
@@ -117,7 +122,8 @@ export function useAuth() {
async function login( async function login(
credentials: { username: string, password: string } credentials: { username: string, password: string }
| { secret: string } | { secret: string },
captcha: LoginCaptchaPayload
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> { ): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
try { try {
const result = await $fetch<{ const result = await $fetch<{
@@ -127,7 +133,11 @@ export function useAuth() {
}>('/api/auth/login', { }>('/api/auth/login', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: credentials body: {
...credentials,
captchaId: captcha.captchaId,
captchaPosition: captcha.captchaPosition
}
}) })
if (result.needsMigration) { if (result.needsMigration) {
return { ok: true, needsMigration: true } return { ok: true, needsMigration: true }
+20 -58
View File
@@ -12,20 +12,13 @@ export function useImageList() {
const totalPages = ref(1) const totalPages = ref(1)
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const folderTotal = ref<number | null>(null) const totalCount = ref<number | null>(null)
const loadingTotal = ref(false) const loadingTotal = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const activeSearch = ref('') const activeSearch = ref('')
const activeFolder = ref('all')
const activeStorageBackend = ref('all') const activeStorageBackend = ref('all')
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([]) const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
function folderQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return {}
return { folder }
}
function storageQueryParams(): Record<string, string> { function storageQueryParams(): Record<string, string> {
const backendId = activeStorageBackend.value.trim() const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return {} if (!backendId || backendId === 'all') return {}
@@ -33,16 +26,7 @@ export function useImageList() {
} }
function listQueryParams(): Record<string, string> { function listQueryParams(): Record<string, string> {
return { return storageQueryParams()
...folderQueryParams(),
...storageQueryParams()
}
}
function matchesActiveFolder(key: string) {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`)
} }
function matchesActiveStorage(item: ImageItem) { function matchesActiveStorage(item: ImageItem) {
@@ -74,9 +58,9 @@ export function useImageList() {
credentials: 'include', credentials: 'include',
query: listQueryParams() query: listQueryParams()
}) })
folderTotal.value = data.total totalCount.value = data.total
} catch { } catch {
folderTotal.value = null totalCount.value = null
} finally { } finally {
loadingTotal.value = false loadingTotal.value = false
} }
@@ -138,27 +122,13 @@ export function useImageList() {
await Promise.all([fetchList(1), fetchTotal()]) await Promise.all([fetchList(1), fetchTotal()])
} }
async function initializeList(folder = 'all') { async function initializeList() {
const next = folder.trim() || 'all'
activeFolder.value = next
page.value = 1 page.value = 1
activeSearch.value = '' activeSearch.value = ''
searchQuery.value = '' searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()]) await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()])
} }
async function setActiveFolder(folder: string) {
const next = folder.trim() || 'all'
if (next === activeFolder.value) return
activeFolder.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function setActiveStorageBackend(backendId: string) { async function setActiveStorageBackend(backendId: string) {
const next = backendId.trim() || 'all' const next = backendId.trim() || 'all'
if (next === activeStorageBackend.value) return if (next === activeStorageBackend.value) return
@@ -192,14 +162,12 @@ export function useImageList() {
} }
function prependItems(newItems: ImageItem[]) { function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => const matched = newItems.filter(item => matchesActiveStorage(item))
matchesActiveFolder(item.key) && matchesActiveStorage(item)
)
if (!matched.length || page.value !== 1) return if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value] items.value = [...matched, ...items.value]
total.value += matched.length total.value += matched.length
if (folderTotal.value !== null) { if (totalCount.value !== null) {
folderTotal.value += matched.length totalCount.value += matched.length
} }
} }
@@ -210,8 +178,8 @@ export function useImageList() {
if (removed > 0) { if (removed > 0) {
total.value = Math.max(0, total.value - removed) total.value = Math.max(0, total.value - removed)
} }
if (folderTotal.value !== null && removed > 0) { if (totalCount.value !== null && removed > 0) {
folderTotal.value = Math.max(0, folderTotal.value - removed) totalCount.value = Math.max(0, totalCount.value - removed)
} }
} }
@@ -223,34 +191,30 @@ export function useImageList() {
}) })
const listSummary = computed(() => { const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
const storageLabel = activeStorageLabel.value const storageLabel = activeStorageLabel.value
if (loadingTotal.value && folderTotal.value === null) { if (loadingTotal.value && totalCount.value === null) {
return storageLabel return storageLabel
? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}` ? `${storageLabel} · ${t('stats.counting')}`
: `${folderLabel} · ${t('stats.counting')}` : t('stats.counting')
} }
if (activeSearch.value) { if (activeSearch.value) {
if (storageLabel) { if (storageLabel) {
return t('stats.summarySearchStorage', { return t('stats.summarySearchStorage', {
folder: folderLabel,
storage: storageLabel,
q: activeSearch.value, q: activeSearch.value,
total: total.value total: total.value,
storage: storageLabel
}) })
} }
return t('stats.summarySearch', { 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) { if (storageLabel) {
return t('stats.summaryTotalStorage', { return t('stats.summaryTotalStorage', {
folder: folderLabel, total: count,
storage: storageLabel, storage: storageLabel
total: count
}) })
} }
return t('stats.summaryTotal', { folder: folderLabel, total: count }) return t('stats.summaryTotal', { total: count })
}) })
return { return {
@@ -259,7 +223,6 @@ export function useImageList() {
totalPages, totalPages,
total, total,
loading, loading,
activeFolder,
activeStorageBackend, activeStorageBackend,
storageBackendOptions, storageBackendOptions,
searchQuery, searchQuery,
@@ -270,7 +233,6 @@ export function useImageList() {
fetchTotal, fetchTotal,
refreshList, refreshList,
initializeList, initializeList,
setActiveFolder,
setActiveStorageBackend, setActiveStorageBackend,
loadStorageBackendOptions, loadStorageBackendOptions,
submitSearch, submitSearch,
+1 -5
View File
@@ -17,8 +17,7 @@ export function useImageUpload() {
async function uploadFiles( async function uploadFiles(
files: File[], files: File[],
folder = 'images', options?: { notify?: boolean }
options?: { notify?: boolean, includeFolder?: boolean }
) { ) {
if (uploading.value) return if (uploading.value) return
@@ -55,9 +54,6 @@ export function useImageUpload() {
})) }))
const formData = new FormData() const formData = new FormData()
if (options?.includeFolder !== false) {
formData.append('folder', folder.trim() || 'images')
}
for (const file of prepared) { for (const file of prepared) {
formData.append('files', file, file.name) formData.append('files', file, file.name)
} }
+5 -10
View File
@@ -3,7 +3,6 @@ const STORAGE_KEY = 'pichost.upload-preferences'
export type CopyFormat = 'url' | 'markdown' | 'html' export type CopyFormat = 'url' | 'markdown' | 'html'
export interface UploadPreferences { export interface UploadPreferences {
defaultFolder: string
compressEnabled: boolean compressEnabled: boolean
autoCopyMarkdown: boolean autoCopyMarkdown: boolean
copyFormat: CopyFormat copyFormat: CopyFormat
@@ -11,7 +10,6 @@ export interface UploadPreferences {
} }
const DEFAULTS: UploadPreferences = { const DEFAULTS: UploadPreferences = {
defaultFolder: 'images',
compressEnabled: false, compressEnabled: false,
autoCopyMarkdown: false, autoCopyMarkdown: false,
copyFormat: 'markdown', copyFormat: 'markdown',
@@ -36,9 +34,6 @@ function readStored(): UploadPreferences {
if (!raw) return { ...DEFAULTS } if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<UploadPreferences> const parsed = JSON.parse(raw) as Partial<UploadPreferences>
return { return {
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
? parsed.defaultFolder.trim()
: DEFAULTS.defaultFolder,
compressEnabled: parsed.compressEnabled === true, compressEnabled: parsed.compressEnabled === true,
autoCopyMarkdown: parsed.autoCopyMarkdown === true, autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat, copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
@@ -55,7 +50,6 @@ function writeStored(prefs: UploadPreferences) {
} }
export function useUploadPreferences() { export function useUploadPreferences() {
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled) const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown) const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat) const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
@@ -64,7 +58,6 @@ export function useUploadPreferences() {
function loadPreferences() { function loadPreferences() {
const stored = readStored() const stored = readStored()
defaultFolder.value = stored.defaultFolder
compressEnabled.value = stored.compressEnabled compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat copyFormat.value = stored.copyFormat
@@ -72,9 +65,12 @@ export function useUploadPreferences() {
loaded.value = true loaded.value = true
} }
if (import.meta.client && !loaded.value) {
loadPreferences()
}
function savePreferences() { function savePreferences() {
writeStored({ writeStored({
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
compressEnabled: compressEnabled.value, compressEnabled: compressEnabled.value,
autoCopyMarkdown: autoCopyMarkdown.value, autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value, copyFormat: copyFormat.value,
@@ -82,14 +78,13 @@ export function useUploadPreferences() {
}) })
} }
watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => { watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
if (loaded.value) { if (loaded.value) {
savePreferences() savePreferences()
} }
}) })
return { return {
defaultFolder,
compressEnabled, compressEnabled,
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
+13 -60
View File
@@ -52,34 +52,13 @@ const authHeader = computed(() =>
const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`) const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
const apiDocs = computed<ApiDocItem[]>(() => { const apiDocs = computed<ApiDocItem[]>(() => {
const uploadDescription = isAdmin.value const uploadDescription = t('api.docs.upload.desc')
? t('api.docs.upload.descAdmin') const uploadCurl = `curl -X POST "${baseUrl.value}/api/images/upload" \\
: t('api.docs.upload.descUser')
const uploadCurl = isAdmin.value
? `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "image=@./demo.png"
${t('api.docs.upload.curlCustomFolder')}
curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "folder=blog" \\
-F "image=@./demo.png"`
: `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\ ${authHeaderFlag.value} \\
-F "image=@./demo.png"` -F "image=@./demo.png"`
const listDescription = isAdmin.value const listDescription = t('api.docs.list.desc')
? t('api.docs.list.descAdmin') const listCurl = `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
: t('api.docs.list.descUser')
const listCurl = isAdmin.value
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}
${t('api.docs.list.curlFolderFilter')}
curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
${authHeaderFlag.value}`
: `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}` ${authHeaderFlag.value}`
return [ return [
@@ -156,10 +135,12 @@ function methodBadgeColor(method: ApiDocItem['method']) {
switch (method) { switch (method) {
case 'GET': case 'GET':
return 'info' as const return 'info' as const
case 'POST':
return 'warning' as const
case 'DELETE': case 'DELETE':
return 'error' as const return 'error' as const
default: default:
return '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() { function closeConfirmRegenerate() {
confirmRegenerateOpen.value = false confirmRegenerateOpen.value = false
} }
@@ -404,18 +376,7 @@ watch(isAuthenticated, async (authed, prev) => {
<p class="mb-2 text-xs font-medium text-muted"> <p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }} {{ t('api.curlExample') }}
</p> </p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950"> <ApiCurlBlock :code="doc.curl" />
<UButton
icon="i-lucide-copy"
:label="t('common.copy')"
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(doc.curl)"
/>
<pre class="max-h-80 overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ doc.curl }}</code></pre>
</div>
</div> </div>
</article> </article>
</div> </div>
@@ -431,7 +392,7 @@ watch(isAuthenticated, async (authed, prev) => {
<div class="min-w-0 space-y-2"> <div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<UBadge <UBadge
color="neutral" :color="methodBadgeColor(twikooDoc.method)"
variant="subtle" variant="subtle"
size="sm" size="sm"
> >
@@ -454,18 +415,10 @@ watch(isAuthenticated, async (authed, prev) => {
<p class="mb-2 text-xs font-medium text-muted"> <p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }} {{ t('api.curlExample') }}
</p> </p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950"> <ApiCurlBlock
<UButton :code="twikooDoc.curl"
icon="i-lucide-copy" :scrollable="false"
:label="t('common.copy')" />
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(twikooDoc.curl)"
/>
<pre class="overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ twikooDoc.curl }}</code></pre>
</div>
</div> </div>
</div> </div>
</section> </section>
+2 -29
View File
@@ -4,17 +4,14 @@ import type { ImageItem } from '~/types/image'
const { uploading, progressItems, uploadFiles } = useImageUpload() const { uploading, progressItems, uploadFiles } = useImageUpload()
const { const {
defaultFolder,
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
loadPreferences loadPreferences
} = useUploadPreferences() } = useUploadPreferences()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
= useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const folderOptions = ref<string[]>(['images'])
const sessionItems = ref<ImageItem[]>([]) const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set()) const deletingKeys = ref<Set<string>>(new Set())
const emptySelection = ref(new Set<string>()) const emptySelection = ref(new Set<string>())
@@ -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() { async function loadPage() {
try { try {
loadPreferences() loadPreferences()
if (isAdmin.value) {
await loadFolders()
}
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
} }
@@ -112,20 +96,9 @@ async function handleUpload(files: File[]) {
} }
try { try {
const uploadTarget = isAdmin.value const result = await uploadFiles(files)
? (defaultFolder.value.trim() || 'images')
: 'images'
const result = await uploadFiles(files, uploadTarget, {
includeFolder: isAdmin.value
})
if (result?.items.length) { if (result?.items.length) {
sessionItems.value = [...result.items, ...sessionItems.value] sessionItems.value = [...result.items, ...sessionItems.value]
if (isAdmin.value) {
const folder = uploadTarget
if (!folderOptions.value.includes(folder)) {
folderOptions.value = [...folderOptions.value, folder]
}
}
if (autoCopyMarkdown.value && result.items[0]) { if (autoCopyMarkdown.value && result.items[0]) {
const first = result.items[0] const first = result.items[0]
const text const text
+9 -9
View File
@@ -38,8 +38,7 @@ const actionItems = computed(() => [
const sourceItems = computed(() => [ const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const }, { label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const }, { label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceApi'), value: 'api' as const }, { label: t('logs.sourceApi'), value: 'api' as const }
{ label: t('logs.sourceTwikoo'), value: 'twikoo' as const }
]) ])
const userItems = computed(() => { const userItems = computed(() => {
@@ -93,8 +92,6 @@ function sourceLabel(source: LogSource) {
return t('logs.sourceWeb') return t('logs.sourceWeb')
case 'api': case 'api':
return t('logs.sourceApi') return t('logs.sourceApi')
case 'twikoo':
return t('logs.sourceTwikoo')
} }
} }
@@ -103,9 +100,12 @@ function storageIcon(storage: ActivityLogStorage | null) {
} }
function publicPathFromKey(key: string) { function publicPathFromKey(key: string) {
if (!hideFolderInUrl.value) return key if (hideFolderInUrl.value) {
const slash = key.indexOf('/') const slash = key.lastIndexOf('/')
return slash > 0 ? key.slice(slash + 1) : key return slash >= 0 ? key.slice(slash + 1) : key
}
const prefix = 'images/'
return key.startsWith(prefix) ? key.slice(prefix.length) : key
} }
function imageUrl(key: string) { function imageUrl(key: string) {
@@ -118,10 +118,10 @@ function imageUrl(key: string) {
async function loadImageLinkBase() { async function loadImageLinkBase() {
try { try {
if (isAdmin.value) { if (isAdmin.value) {
const data = await $fetch<{ imageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', { const data = await $fetch<{ effectiveImageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', {
credentials: 'include' credentials: 'include'
}) })
imageLinkBase.value = data.imageBaseUrl imageLinkBase.value = data.effectiveImageBaseUrl
hideFolderInUrl.value = data.hideFolderInUrl hideFolderInUrl.value = data.hideFolderInUrl
return return
} }
+290 -228
View File
@@ -14,32 +14,47 @@ interface SettingsResponse {
imageBaseUrl: string imageBaseUrl: string
imageBaseUrlConfigured: string imageBaseUrlConfigured: string
imageBaseUrlSource: SettingSource imageBaseUrlSource: SettingSource
effectiveImageBaseUrl: string
siteBaseUrl: string siteBaseUrl: string
siteBaseUrlConfigured: string siteBaseUrlConfigured: string
siteBaseUrlSource: SettingSource siteBaseUrlSource: SettingSource
effectiveSiteBaseUrl: string
domainSeparation: boolean domainSeparation: boolean
runtime: {
currentOrigin: string
currentHost: string
hostRole: 'site' | 'image' | 'unknown' | 'single'
}
hideFolderInUrl: boolean hideFolderInUrl: boolean
hideFolderInUrlSource: SettingSource hideFolderInUrlSource: SettingSource
storageUseDatePath: boolean
storageUseDatePathSource: SettingSource
allowRegistration: boolean allowRegistration: boolean
appVersion: string appVersion: string
} }
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth() const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const { defaultFolder, loadPreferences } = useUploadPreferences()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t, locale } = useI18n()
const domainSeparationDocUrl = computed(() =>
locale.value === 'en'
? 'https://o96u.github.io/PicHost/en/guide/domain-separation'
: 'https://o96u.github.io/PicHost/guide/domain-separation'
)
const settings = ref<SettingsResponse | null>(null) const settings = ref<SettingsResponse | null>(null)
const folderOptions = ref<string[]>(['images'])
const savingServer = ref(false) const savingServer = ref(false)
const refererDraft = ref('') const refererDraft = ref('')
const siteBaseUrlDraft = ref('') const siteBaseUrlDraft = ref('')
const imageBaseUrlDraft = ref('') const imageBaseUrlDraft = ref('')
const hideFolderInUrlDraft = ref(false) const hideFolderInUrlDraft = ref(false)
const storageUseDatePathDraft = ref(true)
const allowRegistrationDraft = ref(false) const allowRegistrationDraft = ref(false)
const domainSeparationDraft = ref(false) const domainSeparationDraft = ref(false)
const hideFolderConfirmOpen = ref(false) const disableDomainSeparationOpen = ref(false)
const pendingDomainSeparationDisable = ref(false)
interface ReleaseCheckResponse { interface ReleaseCheckResponse {
currentVersion: string currentVersion: string
@@ -50,17 +65,14 @@ interface ReleaseCheckResponse {
const releaseCheck = ref<ReleaseCheckResponse | null>(null) const releaseCheck = ref<ReleaseCheckResponse | null>(null)
const folderSelectItems = computed(() =>
folderOptions.value.map(folder => ({ label: folder, value: folder }))
)
const hasServerChanges = computed(() => { const hasServerChanges = computed(() => {
if (!settings.value) return false if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts return refererDraft.value !== settings.value.allowedRefererHosts
|| domainSeparationDraft.value !== settings.value.domainSeparation || domainSeparationDraft.value !== settings.value.domainSeparation
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrlConfigured : '') || siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrl : '')
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured || imageBaseUrlDraft.value !== settings.value.imageBaseUrl
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl || hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|| allowRegistrationDraft.value !== settings.value.allowRegistration || allowRegistrationDraft.value !== settings.value.allowRegistration
}) })
@@ -98,9 +110,10 @@ function errorStatus(error: unknown): number {
function applySettings(data: SettingsResponse) { function applySettings(data: SettingsResponse) {
settings.value = data settings.value = data
refererDraft.value = data.allowedRefererHosts refererDraft.value = data.allowedRefererHosts
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrlConfigured : '' siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrl : ''
imageBaseUrlDraft.value = data.imageBaseUrlConfigured imageBaseUrlDraft.value = data.imageBaseUrl
hideFolderInUrlDraft.value = data.hideFolderInUrl hideFolderInUrlDraft.value = data.hideFolderInUrl
storageUseDatePathDraft.value = data.storageUseDatePath
allowRegistrationDraft.value = data.allowRegistration allowRegistrationDraft.value = data.allowRegistration
domainSeparationDraft.value = data.domainSeparation domainSeparationDraft.value = data.domainSeparation
} }
@@ -126,50 +139,53 @@ function handlePatchError(error: unknown, fallback: string) {
toast.add({ title: fallback, color: 'error' }) toast.add({ title: fallback, color: 'error' })
} }
function onHideFolderDraftChange(next: boolean | 'indeterminate') { function fillDetectedSiteOrigin() {
if (next !== true) { if (settings.value?.runtime.currentOrigin) {
hideFolderInUrlDraft.value = false siteBaseUrlDraft.value = settings.value.runtime.currentOrigin
return
} }
if (folderOptions.value.length > 1) {
hideFolderConfirmOpen.value = true
return
}
hideFolderInUrlDraft.value = true
}
function confirmHideFolderEnable() {
hideFolderInUrlDraft.value = true
hideFolderConfirmOpen.value = false
}
function cancelHideFolderEnable() {
hideFolderInUrlDraft.value = false
hideFolderConfirmOpen.value = false
} }
function onDomainSeparationDraftChange(next: boolean | 'indeterminate') { function onDomainSeparationDraftChange(next: boolean | 'indeterminate') {
const enabled = next === true const enabled = next === true
domainSeparationDraft.value = enabled if (!enabled && domainSeparationDraft.value) {
if (!enabled) { pendingDomainSeparationDisable.value = true
siteBaseUrlDraft.value = '' disableDomainSeparationOpen.value = true
return return
} }
if (!siteBaseUrlDraft.value && import.meta.client) { domainSeparationDraft.value = enabled
siteBaseUrlDraft.value = window.location.origin }
}
function confirmDisableDomainSeparation() {
domainSeparationDraft.value = false
siteBaseUrlDraft.value = ''
disableDomainSeparationOpen.value = false
pendingDomainSeparationDisable.value = false
}
function cancelDisableDomainSeparation() {
disableDomainSeparationOpen.value = false
pendingDomainSeparationDisable.value = false
} }
async function saveServerSettings() { async function saveServerSettings() {
if (!isAuthenticated.value || !hasServerChanges.value) return if (!isAuthenticated.value || !hasServerChanges.value) return
if (domainSeparationDraft.value) {
if (!siteBaseUrlDraft.value.trim() || !imageBaseUrlDraft.value.trim()) {
toast.add({ title: t('settings.domainSeparationRequired'), color: 'error' })
return
}
}
savingServer.value = true savingServer.value = true
try { try {
await patchSettings({ await patchSettings({
allowedRefererHosts: refererDraft.value, allowedRefererHosts: refererDraft.value,
domainSeparation: domainSeparationDraft.value,
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '', siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
imageBaseUrl: imageBaseUrlDraft.value, imageBaseUrl: imageBaseUrlDraft.value,
hideFolderInUrl: hideFolderInUrlDraft.value, hideFolderInUrl: hideFolderInUrlDraft.value,
storageUseDatePath: storageUseDatePathDraft.value,
allowRegistration: allowRegistrationDraft.value allowRegistration: allowRegistrationDraft.value
}) })
toast.add({ title: t('settings.saved'), color: 'success' }) toast.add({ title: t('settings.saved'), color: 'success' })
@@ -180,16 +196,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() { async function loadSettings() {
try { try {
const data = await $fetch<SettingsResponse>('/api/settings', { const data = await $fetch<SettingsResponse>('/api/settings', {
@@ -219,8 +225,7 @@ async function loadPage() {
await navigateTo('/') await navigateTo('/')
return return
} }
loadPreferences() await Promise.all([loadSettings(), checkLatestRelease()])
await Promise.all([loadSettings(), loadFolders(), checkLatestRelease()])
} }
onMounted(async () => { onMounted(async () => {
@@ -299,194 +304,56 @@ watch(isAuthenticated, async (authed, prev) => {
class="p-5 sm:p-6" class="p-5 sm:p-6"
> >
<div class="space-y-5"> <div class="space-y-5">
<div class="grid gap-5 lg:grid-cols-2"> <div class="grid min-w-0 gap-5 lg:grid-cols-2 lg:items-stretch">
<div class="space-y-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5"> <div class="flex h-full min-w-0 flex-col space-y-3 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold"> <h3 class="text-sm font-semibold">
{{ t('settings.uploadAccount') }} {{ t('settings.featureToggles') }}
</h3> </h3>
<div class="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="w-full"
/>
</div>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3"> <label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox <UCheckbox
v-model="allowRegistrationDraft" v-model="allowRegistrationDraft"
class="mt-0.5" class="mt-0.5 shrink-0"
/> />
<span> <span class="min-w-0">
<span class="block text-sm font-medium">{{ t('settings.allowRegistration') }}</span> <span class="block text-sm font-medium">{{ t('settings.allowRegistration') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted"> <span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.allowRegistrationHint') }} {{ t('settings.allowRegistrationHint') }}
</span> </span>
</span> </span>
</label> </label>
</div>
<div class="flex flex-col gap-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5"> <label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<div class="flex items-center justify-between gap-2"> <UCheckbox
<h3 class="text-sm font-semibold"> :model-value="storageUseDatePathDraft"
{{ t('settings.accessControl') }} class="mt-0.5 shrink-0"
</h3> :disabled="settings.storageUseDatePathSource === 'env'"
<UBadge @update:model-value="(v) => storageUseDatePathDraft = v === true"
:color="sourceBadge(settings.refererSource).color" />
variant="subtle" <span class="min-w-0">
size="xs" <span class="flex flex-wrap items-center gap-2 text-sm font-medium">
> {{ t('settings.storageUseDatePath') }}
{{ sourceBadge(settings.refererSource).label }} <UBadge
</UBadge> v-if="settings.storageUseDatePathSource === 'env'"
</div> :color="sourceBadge(settings.storageUseDatePathSource).color"
<p class="text-xs leading-relaxed text-muted"> variant="subtle"
{{ t('settings.refererHint') }} size="xs"
</p> >
<UTextarea {{ sourceBadge(settings.storageUseDatePathSource).label }}
v-model="refererDraft" </UBadge>
:placeholder="t('settings.refererPlaceholder')" </span>
:rows="5" <span class="mt-1 block text-xs leading-relaxed text-muted">
class="min-h-0 w-full flex-1 font-mono text-sm" {{ t('settings.storageUseDatePathHint') }}
:ui="{ root: 'flex-1' }" </span>
/>
</div>
</div>
<div class="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.linkGeneration') }}
</h3>
<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"
class="mt-0.5"
@update:model-value="onDomainSeparationDraftChange"
/>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t('setup.domainSeparation') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('setup.domainSeparationHint') }}
</span> </span>
</span> </label>
</label>
<p <label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
v-if="domainSeparationDraft"
class="text-xs leading-relaxed text-warning"
>
{{ t('setup.domainSeparationProxyHint') }}
<a
href="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{{ t('setup.domainSeparationProxyExample') }}
</a>
</p>
<div
v-if="domainSeparationDraft"
class="grid gap-4 lg:grid-cols-2"
>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<label class="text-sm font-medium">{{ t('settings.siteBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.siteBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.siteBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.siteBaseUrlHint') }}
</p>
<UInput
v-model="siteBaseUrlDraft"
:placeholder="t('settings.siteBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.siteBaseUrl"
class="text-xs text-muted"
>
{{ t('settings.siteBaseUrlActive', { url: settings.siteBaseUrl }) }}
</p>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="text-xs text-muted"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
</div>
<div
v-else
class="space-y-1.5"
>
<div class="flex items-center justify-between gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlSingleHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="text-xs text-muted"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
<div class="border-t border-default/60 pt-4">
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox <UCheckbox
:model-value="hideFolderInUrlDraft" :model-value="hideFolderInUrlDraft"
class="mt-0.5" class="mt-0.5 shrink-0"
:disabled="settings.hideFolderInUrlSource === 'env'" :disabled="settings.hideFolderInUrlSource === 'env'"
@update:model-value="onHideFolderDraftChange" @update:model-value="(v) => hideFolderInUrlDraft = v === true"
/> />
<span class="min-w-0"> <span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium"> <span class="flex flex-wrap items-center gap-2 text-sm font-medium">
@@ -506,6 +373,183 @@ watch(isAuthenticated, async (authed, prev) => {
</span> </span>
</label> </label>
</div> </div>
<div class="flex h-full min-w-0 flex-col space-y-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold">
{{ t('settings.domainSettings') }}
</h3>
<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"
class="mt-0.5 shrink-0"
@update:model-value="onDomainSeparationDraftChange"
/>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t('setup.domainSeparation') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('setup.domainSeparationHint') }}
</span>
</span>
</label>
<p
v-if="domainSeparationDraft"
class="rounded-lg border border-warning/25 bg-warning/5 px-3 py-2.5 text-xs leading-relaxed text-warning"
>
{{ t('setup.domainSeparationProxyHint') }}
<a
:href="domainSeparationDocUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{{ t('setup.domainSeparationProxyExample') }}
</a>
</p>
<p
v-if="settings.runtime.currentOrigin"
class="text-xs text-muted"
>
{{ t('settings.runtimeDetected', { url: settings.runtime.currentOrigin }) }}
</p>
<p
v-if="domainSeparationDraft && settings.runtime.hostRole === 'unknown'"
class="rounded-lg border border-error/25 bg-error/5 px-3 py-2.5 text-xs leading-relaxed text-error"
>
{{ t('settings.hostRoleUnknown') }}
</p>
<div
v-if="domainSeparationDraft"
class="grid gap-4 sm:grid-cols-2"
>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.siteBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.siteBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.siteBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.siteBaseUrlHint') }}
</p>
<div class="flex gap-2">
<UInput
v-model="siteBaseUrlDraft"
:placeholder="t('settings.siteBaseUrlPlaceholder')"
class="min-w-0 flex-1 font-mono text-sm"
/>
<UButton
v-if="settings.runtime.currentOrigin"
:label="t('settings.fillDetectedOrigin')"
color="neutral"
variant="outline"
size="sm"
class="shrink-0"
@click="fillDetectedSiteOrigin"
/>
</div>
<p
v-if="settings.effectiveSiteBaseUrl"
class="truncate text-xs text-muted"
:title="settings.effectiveSiteBaseUrl"
>
{{ t('settings.siteBaseUrlActive', { url: settings.effectiveSiteBaseUrl }) }}
</p>
</div>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.effectiveImageBaseUrl"
class="truncate text-xs text-muted"
:title="settings.effectiveImageBaseUrl"
>
{{ t('settings.imageBaseUrlActive', { url: settings.effectiveImageBaseUrl }) }}
</p>
</div>
</div>
<div
v-else
class="space-y-1.5"
>
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlSingleHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.effectiveImageBaseUrl"
class="truncate text-xs text-muted"
:title="settings.effectiveImageBaseUrl"
>
{{ t('settings.imageBaseUrlActive', { url: settings.effectiveImageBaseUrl }) }}
</p>
</div>
</div>
</div>
<div class="settings-referer-section space-y-1.5 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
{{ t('settings.accessControl') }}
</h3>
<UBadge
:color="sourceBadge(settings.refererSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.refererSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.refererHint') }}
</p>
<UTextarea
v-model="refererDraft"
:placeholder="t('settings.refererPlaceholder')"
:rows="4"
:autoresize="false"
class="settings-referer-field w-full font-mono text-sm"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -522,14 +566,14 @@ watch(isAuthenticated, async (authed, prev) => {
<div <div
v-if="settings" v-if="settings"
class="flex flex-wrap items-center justify-between gap-3 border-t border-default px-5 py-4 sm:px-6" class="flex flex-col gap-3 border-t border-default px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6"
> >
<div class="flex min-w-0 items-center gap-2 text-xs text-muted"> <div class="flex min-w-0 items-center gap-2 text-xs text-muted">
<UIcon <UIcon
name="i-lucide-key-round" name="i-lucide-key-round"
class="size-3.5 shrink-0" class="size-3.5 shrink-0"
/> />
<span> <span class="min-w-0">
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }} {{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }}
<a <a
:href="releaseCheck?.updateAvailable && releaseCheck.releaseUrl :href="releaseCheck?.updateAvailable && releaseCheck.releaseUrl
@@ -563,7 +607,7 @@ watch(isAuthenticated, async (authed, prev) => {
</template> </template>
</span> </span>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center justify-end gap-2">
<UButton <UButton
:label="t('settings.manageToken')" :label="t('settings.manageToken')"
icon="i-lucide-external-link" icon="i-lucide-external-link"
@@ -586,10 +630,10 @@ watch(isAuthenticated, async (authed, prev) => {
</AppShell> </AppShell>
<UModal <UModal
:open="hideFolderConfirmOpen" :open="disableDomainSeparationOpen"
:title="t('settings.hideFolderConfirmTitle')" :title="t('settings.disableDomainSeparationTitle')"
:description="t('settings.hideFolderConfirmDesc')" :description="t('settings.disableDomainSeparationDesc')"
@update:open="(open) => { if (!open) cancelHideFolderEnable() }" @update:open="(v) => { if (!v) cancelDisableDomainSeparation() }"
> >
<template #footer> <template #footer>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
@@ -597,15 +641,33 @@ watch(isAuthenticated, async (authed, prev) => {
:label="t('common.cancel')" :label="t('common.cancel')"
color="neutral" color="neutral"
variant="outline" variant="outline"
@click="cancelHideFolderEnable" @click="cancelDisableDomainSeparation"
/> />
<UButton <UButton
:label="t('settings.hideFolderConfirmEnable')" :label="t('settings.disableDomainSeparationConfirm')"
color="warning" color="warning"
@click="confirmHideFolderEnable" @click="confirmDisableDomainSeparation"
/> />
</div> </div>
</template> </template>
</UModal> </UModal>
</div> </div>
</template> </template>
<style scoped>
.settings-referer-section {
min-width: 0;
}
.settings-referer-field :deep(textarea),
.settings-referer-field :deep(> div) {
box-sizing: border-box;
width: 100%;
min-width: 0;
}
.settings-referer-field :deep(textarea) {
min-height: 5.5rem;
resize: none;
}
</style>
+13 -6
View File
@@ -7,7 +7,13 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { setup, migrate, fetchStatus } = useAuth() const { setup, migrate, fetchStatus } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t, locale } = useI18n()
const domainSeparationDocUrl = computed(() =>
locale.value === 'en'
? 'https://o96u.github.io/PicHost/en/guide/domain-separation'
: 'https://o96u.github.io/PicHost/guide/domain-separation'
)
const needsMigration = ref(false) const needsMigration = ref(false)
const isMigrate = computed(() => needsMigration.value || route.query.migrate === '1') const isMigrate = computed(() => needsMigration.value || route.query.migrate === '1')
@@ -43,11 +49,12 @@ onMounted(async () => {
await router.replace('/') await router.replace('/')
return return
} }
if (import.meta.client) {
siteBaseUrl.value = window.location.origin
}
}) })
const detectedOriginPlaceholder = computed(() =>
import.meta.client ? window.location.origin : ''
)
async function submit() { async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value) return
@@ -196,7 +203,7 @@ async function submit() {
<p class="text-xs leading-relaxed text-warning"> <p class="text-xs leading-relaxed text-warning">
{{ t('setup.domainSeparationProxyHint') }} {{ t('setup.domainSeparationProxyHint') }}
<a <a
href="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md" :href="domainSeparationDocUrl"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary hover:underline" class="text-primary hover:underline"
@@ -211,7 +218,7 @@ async function submit() {
</p> </p>
<UInput <UInput
v-model="siteBaseUrl" v-model="siteBaseUrl"
:placeholder="t('setup.siteBaseUrlPlaceholder')" :placeholder="detectedOriginPlaceholder || t('setup.siteBaseUrlPlaceholder')"
class="w-full font-mono text-sm" class="w-full font-mono text-sm"
/> />
</div> </div>
+59 -123
View File
@@ -12,17 +12,7 @@ interface StatsResponse {
bySource: { bySource: {
web: number web: number
api: number api: number
twikoo: number
} }
byFolder: Array<{
folder: string
count: number
bytes: number
}>
byFolderUploads: Array<{
folder: string
count: number
}>
} }
const { const {
@@ -38,7 +28,6 @@ const {
fetchList, fetchList,
fetchSearch, fetchSearch,
fetchTotal, fetchTotal,
setActiveFolder,
submitSearch, submitSearch,
goToPage, goToPage,
removeItems, removeItems,
@@ -63,14 +52,7 @@ const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([]) const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false) const batchDeleting = ref(false)
const showScrollTop = ref(false) const showScrollTop = ref(false)
const currentFolder = ref('all')
const currentStorage = ref('all') const currentStorage = ref('all')
const folderOptions = ref<string[]>(['images'])
const folderSelectItems = computed(() => [
{ label: t('stats.allFolders'), value: 'all' },
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
])
const storageSelectItems = computed(() => [ const storageSelectItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' }, { label: t('stats.filterStorageAll'), value: 'all' },
@@ -82,41 +64,29 @@ const storageSelectItems = computed(() => [
const selectedCount = computed(() => selectedKeys.value.size) const selectedCount = computed(() => selectedKeys.value.size)
const FOLDER_CHART_COLORS = [ const SOURCE_CHART_COLORS = [
'var(--ui-primary)', 'var(--ui-primary)',
'#10b981', '#10b981'
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4'
] ]
const folderUploadItems = computed(() => const sourceUploadItems = computed(() => {
(stats.value?.byFolderUploads ?? []).map((item, index) => ({ const bySource = stats.value?.bySource
key: item.folder, if (!bySource) return []
label: item.folder, return [
count: item.count, { key: 'web', label: t('stats.sourceWeb'), count: bySource.web, color: SOURCE_CHART_COLORS[0]! },
color: FOLDER_CHART_COLORS[index % FOLDER_CHART_COLORS.length]! { 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(() => function sourcePercent(count: number) {
folderUploadItems.value.map(item => ({ const total = sourceUploadTotal.value
label: item.label, if (!total) return 0
value: item.count, return Math.round((count / total) * 100)
color: item.color }
}))
)
const folderStats = computed(() => stats.value?.byFolder ?? [])
const folderMaxBytes = computed(() =>
Math.max(...folderStats.value.map(item => item.bytes), 1)
)
const uploadChartTotal = computed(() =>
folderUploadItems.value.reduce((sum, item) => sum + item.count, 0)
)
const statCards = computed(() => { const statCards = computed(() => {
const s = stats.value const s = stats.value
@@ -143,17 +113,6 @@ function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
function folderPercent(bytes: number) {
return Math.round((bytes / folderMaxBytes.value) * 100)
}
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
}
async function fetchStats() { async function fetchStats() {
stats.value = await $fetch<StatsResponse>('/api/stats', { stats.value = await $fetch<StatsResponse>('/api/stats', {
credentials: 'include' credentials: 'include'
@@ -178,7 +137,7 @@ async function refreshGallery() {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedKeys.value = new Set() selectedKeys.value = new Set()
try { try {
await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()]) await Promise.all([refreshList(), loadStorageBackendOptions()])
currentStorage.value = activeStorageBackend.value currentStorage.value = activeStorageBackend.value
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
@@ -202,7 +161,6 @@ async function refreshAll() {
await Promise.all([ await Promise.all([
fetchStats(), fetchStats(),
reloadGallery(), reloadGallery(),
loadFolders(),
loadStorageBackendOptions() loadStorageBackendOptions()
]) ])
currentStorage.value = activeStorageBackend.value 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) { async function handleStorageChange(backendId: string) {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
currentStorage.value = backendId currentStorage.value = backendId
@@ -304,6 +251,10 @@ async function confirmDelete() {
color: 'success' color: 'success'
}) })
await refreshStats() await refreshStats()
await reloadGallery()
if (items.value.length === 0 && galleryPage.value > 1) {
await goToPage(galleryPage.value - 1)
}
} }
if (failed.length) { if (failed.length) {
@@ -406,67 +357,60 @@ watch(isAuthenticated, async (authed, prev) => {
/> />
</div> </div>
<section class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <section class="grid gap-3 lg:grid-cols-3 lg:items-stretch">
<div <div class="grid grid-cols-2 gap-3 lg:col-span-2 lg:grid-cols-4">
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>
<div <div
v-if="!uploadChartTotal" v-for="item in statCards"
class="py-6 text-center text-xs text-muted" :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> </div>
<StatsDonut
v-else
:segments="donutSegments"
:size="88"
/>
</div> </div>
<div class="rounded-xl border border-default bg-elevated p-4"> <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="mb-3 text-sm font-medium text-muted"> <h2 class="shrink-0 text-sm font-medium text-muted">
{{ t('stats.folderUsage') }} {{ t('stats.sourceDistribution') }}
</h2> </h2>
<div <div
v-if="!folderStats.length" v-if="!sourceUploadTotal"
class="py-6 text-center text-xs text-muted" class="flex flex-1 items-center justify-center text-center text-xs text-muted"
> >
{{ t('stats.noData') }} {{ t('stats.noData') }}
</div> </div>
<ul <ul
v-else v-else
class="space-y-2.5" class="mt-4 flex min-h-0 flex-1 flex-col"
> >
<li <li
v-for="item in folderStats" v-for="item in sourceUploadItems"
:key="item.folder" :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"> <div class="flex items-center justify-between gap-2 text-xs">
<span class="font-medium">{{ item.folder }}</span> <span class="flex min-w-0 items-center gap-1.5">
<span class="tabular-nums text-muted"> <span
{{ t('stats.folderCount', { count: item.count, size: formatFileSize(item.bytes) }) }} 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> </span>
</div> </div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50"> <div class="h-2 rounded-full bg-muted/40">
<div <div
class="h-full rounded-full bg-primary/70" class="h-full rounded-full transition-[width]"
:style="{ width: `${Math.max(folderPercent(item.bytes), item.bytes ? 4 : 0)}%` }" :style="{
width: `${sourcePercent(item.count)}%`,
backgroundColor: item.color
}"
/> />
</div> </div>
</li> </li>
@@ -500,14 +444,6 @@ watch(isAuthenticated, async (authed, prev) => {
> >
{{ t('stats.selected', { n: selectedCount }) }} {{ t('stats.selected', { n: selectedCount }) }}
</UBadge> </UBadge>
<USelect
v-model="currentFolder"
:items="folderSelectItems"
class="w-[5.5rem] shrink-0 sm:w-32"
size="sm"
:aria-label="t('stats.folder')"
@update:model-value="handleFolderChange"
/>
<USelect <USelect
v-model="currentStorage" v-model="currentStorage"
:items="storageSelectItems" :items="storageSelectItems"
+8 -15
View File
@@ -1,15 +1,10 @@
export type LogAction = 'upload' | 'delete' export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo' export type LogSource = 'web' | 'api'
export interface ActivityLogUser {
id: number
username: string
}
export interface ActivityLogStorage { export interface ActivityLogStorage {
id: string id: string
name: string name: string
type: 'local' | 's3' type: string
} }
export interface ActivityLogItem { export interface ActivityLogItem {
@@ -26,18 +21,16 @@ export interface ActivityLogItem {
createdAt: string createdAt: string
} }
export interface ActivityLogSummary {
total: number
upload: number
delete: number
}
export interface ActivityLogListResponse { export interface ActivityLogListResponse {
items: ActivityLogItem[] items: ActivityLogItem[]
total: number total: number
page: number page: number
pageSize: number pageSize: number
totalPages: number totalPages: number
summary: ActivityLogSummary summary: {
users?: ActivityLogUser[] total: number
upload: number
delete: number
}
users?: Array<{ id: number, username: string }>
} }
+1
View File
@@ -10,6 +10,7 @@ services:
- "6892:6892" - "6892:6892"
# 首次访问 Web 引导创建管理员,无需预先配置环境变量 # 首次访问 Web 引导创建管理员,无需预先配置环境变量
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等 # 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
# 运维:docker exec pichost reset-password | migrate [--apply]
volumes: volumes:
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data) # 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
- ./data:/data - ./data:/data
+5 -1
View File
@@ -2,7 +2,11 @@
set -e set -e
if [ "$1" = "reset-password" ]; then 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 fi
exec node /app/.output/server/index.mjs "$@" exec node /app/.output/server/index.mjs "$@"
+142
View File
@@ -0,0 +1,142 @@
import { defineConfig } from 'vitepress'
import { withMermaid } from 'vitepress-plugin-mermaid'
const docsBase = 'https://o96u.github.io/PicHost'
const zhSidebar = [
{
text: '开始',
items: [
{ text: '文档首页', link: '/guide/' },
{ text: '快速开始', link: '/guide/getting-started' },
{ text: '本地开发', link: '/guide/local-dev' }
]
},
{
text: '部署与配置',
items: [
{ text: '环境变量', link: '/guide/configuration' },
{ text: '反向代理', link: '/guide/reverse-proxy' },
{ text: '双域名分离', link: '/guide/domain-separation' },
{ text: 'Cloudflare / CF 优选', link: '/guide/cloudflare-deployment' }
]
},
{
text: '使用',
items: [
{ text: '存储', link: '/guide/storage' },
{ text: '用户与权限', link: '/guide/users-and-permissions' },
{ text: 'API', link: '/guide/api' },
{ text: 'Twikoo', link: '/guide/twikoo' }
]
},
{
text: '升级',
items: [
{ text: '更新日志', link: '/guide/changelog' },
{ text: 'v1.2 迁移', link: '/guide/migration' },
{ text: '常见问题', link: '/guide/faq' }
]
}
]
const enSidebar = [
{
text: 'Getting started',
items: [
{ text: 'Overview', link: '/en/guide/' },
{ text: 'Quick start', link: '/en/guide/getting-started' },
{ text: 'Local development', link: '/en/guide/local-dev' }
]
},
{
text: 'Deploy & configure',
items: [
{ text: 'Environment variables', link: '/en/guide/configuration' },
{ text: 'Reverse proxy', link: '/en/guide/reverse-proxy' },
{ text: 'Dual-domain separation', link: '/en/guide/domain-separation' },
{ text: 'Cloudflare deployment', link: '/en/guide/cloudflare-deployment' }
]
},
{
text: 'Usage',
items: [
{ text: 'Storage', link: '/en/guide/storage' },
{ text: 'Users & permissions', link: '/en/guide/users-and-permissions' },
{ text: 'API', link: '/en/guide/api' },
{ text: 'Twikoo', link: '/en/guide/twikoo' }
]
},
{
text: 'Upgrade',
items: [
{ text: 'Changelog', link: '/en/guide/changelog' },
{ text: 'v1.2 migration', link: '/en/guide/migration' },
{ text: 'FAQ', link: '/en/guide/faq' }
]
}
]
export default withMermaid(
defineConfig({
base: '/PicHost/',
title: 'PicHost',
description: '自托管图床文档 · Self-hosted image hosting docs',
head: [['link', { rel: 'icon', href: '/PicHost/favicon.ico' }]],
vite: {
optimizeDeps: {
// mermaid → fastdom 为 CJS;dev 模式须预构建,否则整站白屏
include: ['mermaid', 'fastdom']
}
},
locales: {
root: {
label: '简体中文',
lang: 'zh-CN',
themeConfig: {
nav: [
{ text: '指南', link: '/guide/getting-started' },
{ text: 'API', link: '/guide/api' },
{ text: 'GitHub', link: 'https://github.com/O96u/PicHost' }
],
sidebar: zhSidebar,
docFooter: { prev: '上一页', next: '下一页' },
outline: { label: '本页目录' },
lastUpdated: { text: '最后更新' },
returnToTopLabel: '回到顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '主题',
langMenuLabel: '语言'
}
},
en: {
label: 'English',
lang: 'en-US',
link: '/en/',
themeConfig: {
nav: [
{ text: 'Guide', link: '/en/guide/getting-started' },
{ text: 'API', link: '/en/guide/api' },
{ text: 'GitHub', link: 'https://github.com/O96u/PicHost' }
],
sidebar: enSidebar,
docFooter: { prev: 'Previous', next: 'Next' },
outline: { label: 'On this page' },
lastUpdated: { text: 'Last updated' },
returnToTopLabel: 'Back to top',
sidebarMenuLabel: 'Menu',
darkModeSwitchLabel: 'Appearance',
langMenuLabel: 'Language'
}
}
},
themeConfig: {
socialLinks: [{ icon: 'github', link: 'https://github.com/O96u/PicHost' }],
search: { provider: 'local' },
editLink: {
pattern: `${docsBase}/edit/main/docs-site/:path`,
text: '在 GitHub 上编辑此页'
}
}
})
)
+6
View File
@@ -0,0 +1,6 @@
:root {
--vp-c-brand-1: #22c55e;
--vp-c-brand-2: #16a34a;
--vp-c-brand-3: #15803d;
--vp-c-brand-soft: rgba(34, 197, 94, 0.14);
}
+4
View File
@@ -0,0 +1,4 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
export default DefaultTheme
+16
View File
@@ -0,0 +1,16 @@
import { cpSync, existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const src = join(root, 'docs', 'screenshots')
const dest = join(root, 'docs-site', 'public', 'screenshots')
if (!existsSync(src)) {
console.error(`Screenshot source missing: ${src}`)
process.exit(1)
}
mkdirSync(dest, { recursive: true })
cpSync(src, dest, { recursive: true })
console.log(`Copied screenshots to ${dest}`)
+99
View File
@@ -0,0 +1,99 @@
# API
PicHost exposes REST endpoints and a Twikoo-compatible upload. After deployment, open the in-app **API** page for your token and copyable cURL snippets.
## Authentication
All REST calls use:
```http
Auth-Token: YOUR_TOKEN
```
| Token type | How to obtain | Notes |
| ---------- | ------------- | ----- |
| **Global** | Admin **API** page or `API_UPLOAD_TOKEN` env | Env wins and blocks UI regenerate |
| **Personal** | Each user’s **API** page | Uploads belong to that user |
Form uploads may use field `token` (Twikoo protocol).
## Endpoints
### 1. Upload images
`POST /api/images/upload`
Upload one or more images via `image`; also accepts `file`, `files`. Stored under `images/`.
```bash
curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png"
```
### 2. List images
`GET /api/images`
Paginated gallery. `limit` default 20, max 100.
```bash
curl "https://admin.example.com/api/images?limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 3. Search images
`GET /api/images/search`
Search by filename or path; `q` is required.
```bash
curl "https://admin.example.com/api/images/search?q=demo&limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 4. Delete image
`DELETE /api/images`
Delete by `key` (storage path, e.g. `images/2026/08/xxx.webp`).
```bash
curl -X DELETE "https://admin.example.com/api/images?key=images/2026/08/xxxx.webp" \
-H "Auth-Token: YOUR_TOKEN"
```
### 5. Batch delete
`POST /api/images/batch-delete`
JSON body: `{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}`
```bash
curl -X POST "https://admin.example.com/api/images/batch-delete" \
-H "Auth-Token: YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'
```
## Errors
REST responses are JSON with `code` and `message` (`UNAUTHORIZED`, `FORBIDDEN`, `INVALID_REQUEST`, etc.). Twikoo uses EasyImage-compatible JSON.
## Visibility
- User token: list/search/delete own images only
- Admin token: all images
- Global token uploads belong to admin
See [Users & permissions](./users-and-permissions.md).
## Twikoo
`POST /api/index.php` — [Twikoo](./twikoo.md).
## See also
- [Environment variables](./configuration.md)
- [Reverse proxy](./reverse-proxy.md)
+121
View File
@@ -0,0 +1,121 @@
# Changelog
Version history and notable changes. The full log is also in the repo root [CHANGELOG.md](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md).
---
## [1.2.3] — 2026-08-30
### Fix
- **Short links + date-based storage**: bare filename URLs (e.g. `https://host/xxx.webp`) no longer map to flat `images/xxx.webp`; SQLite index resolves the full `images/YYYY/MM/xxx.webp` key so thumbnails and short links work again
---
## [1.2.2] — 2026-08-30
> **Restart the service** after upgrading so middleware and API updates take effect.
### Dual-domain settings & Host isolation
#### Fixes
| Issue | Description |
| ----- | ----------- |
| Site URL cleared by mistake | Saving Settings could write an empty `site_base_url` when disabling dual-domain or editing only the image URL |
| Config vs effective URL mixed | `GET /api/settings` used to blend DB values with `request.origin`, confusing the Settings UI |
| Third-host bypass | Unlisted hosts (`pages.dev`, bare IP, old image domains) were not blocked by middleware |
#### Improvements
**Settings API (`GET /api/settings`)**
| Field | Meaning |
| ----- | ------- |
| `siteBaseUrl` / `imageBaseUrl` | **Configured** values (DB → env), no request fallback |
| `effectiveSiteBaseUrl` / `effectiveImageBaseUrl` | **Link generation**, falls back to request origin when unset |
| `runtime.currentOrigin` | Detected access URL (display only, not saved) |
| `runtime.hostRole` | `site` / `image` / `unknown` / `single` |
**Save API (`PATCH /api/settings`)**
- New body field `domainSeparation` (`true` / `false`)
- `domainSeparation: true` requires both URLs with different hostnames
- `domainSeparation: false` allows clearing the site URL
- Without `domainSeparation`, cannot clear `siteBaseUrl` while dual-domain is active
**Settings / setup UI**
- Form uses configured values; “Active” line shows `effective*`
- Shows detected access URL; **no longer** auto-fills `window.location.origin`
- “Use detected URL” button (opt-in)
- Confirmation modal when disabling dual-domain
- Warning when `hostRole === 'unknown'` with dual-domain on
**Host middleware**
- When dual-domain is on, unlisted hosts get **404** at the app layer (all methods)
- `localhost` / `127.0.0.1` exempt in development
- Complements Nginx `default_server` — use both layers in production
#### Docs
- Rewrote [Cloudflare deployment](./cloudflare-deployment.md)
- Updated [Dual-domain separation](./domain-separation.md) for third-host blocking
- Added this changelog page
#### Upgrade notes
- **No** database migration or CLI step
- If the site URL was cleared earlier: re-enter it in **Settings**, or set `SITE_BASE_URL` in env and restart
- Production: match proxy `server_name` to Settings; keep a default server block
---
## [1.2.1] — 2026-08-29
### Added
- Slide puzzle login captcha
- VitePress docs site on GitHub Pages
- `npm run docs:dev` / `docs:build` / `docs:preview`
### Fixed
- Gallery not refreshing after delete on stats page
- Pagination jump with `type="number"` input
- Copy-link format preference not persisting off home route
- Captcha UI alignment
### Improved
- README trimmed; details moved to docs
- Dual-domain docs: unknown Host risk, Cloudflare guide
---
## [1.2.0] — 2026-08-28
### Changed
- All images under `data/images/`; legacy `blog/` / `twikoo/` need migration
### Added
- CLI: `docker exec pichost migrate` / `migrate --apply`
- Startup index sync (disk scan, legacy key normalization, orphan cleanup)
See [v1.2 migration](./migration.md).
---
## [1.1.x] summary
| Version | Highlights |
| ------- | ---------- |
| 1.1.5 | API upload preview fix; file existence check before serve |
| 1.1.4 | Date-based storage paths; site host blocks image URLs |
| 1.1.3 | Dual-domain (`SITE_BASE_URL` / `IMAGE_BASE_URL`); Host middleware |
| 1.1.0 | Multi storage backends (S3 / R2) |
Older entries: [GitHub CHANGELOG](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md).
+236
View File
@@ -0,0 +1,236 @@
# Cloudflare & preferred-edge deployment
In dual-domain mode, PicHost distinguishes **site** vs **image** traffic from the request **Host** (and `X-Forwarded-Host` from a trusted reverse proxy). Cloudflare orange cloud, preferred-edge IPs, Workers, and Pages are all fine — as long as the hostname identity reaching PicHost stays correct.
> **One-line rule:** The network path may change; the hostname identity must not — `admin → admin`, `img → img`, never `img → admin`.
See [Dual-domain separation](./domain-separation.md) for middleware behavior and [Reverse proxy](./reverse-proxy.md) for general proxy requirements.
---
## Quick checklist
| # | Check | Expected |
| - | ----- | -------- |
| 1 | Browser hostname | `admin.*` or `img.*`; do not mix IP / `pages.dev` |
| 2 | Pages / Worker in the middle | Image host must **not** `fetch(admin…)` |
| 3 | Reverse-proxy upstream | Both hostnames → `http://127.0.0.1:6892` |
| 4 | Nginx `proxy_set_header Host` | `$host` (preserve client hostname) |
| 5 | PicHost settings | `SITE_BASE_URL` / `IMAGE_BASE_URL` match `server_name` |
| 6 | Port `6892` | Reachable only from localhost or internal proxy |
---
## Recommended layout
Both hostnames go through Cloudflare (proxied) and **independently** reverse-proxy to one PicHost instance:
```mermaid
flowchart TB
internet[Internet]
cf[Cloudflare proxied]
admin[admin.example.com]
img[img.example.com]
panel[1Panel / Nginx]
app[PicHost :6892]
internet --> cf
cf --> admin
cf --> img
admin --> panel
img --> panel
panel --> app
```
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://img.example.com
```
```text
admin.example.com → http://127.0.0.1:6892
img.example.com → http://127.0.0.1:6892
```
You do **not** need separate ports for “frontend / backend / images” — one PicHost instance on port `6892` is enough.
### Site hostname ≠ frontend domain
| Hostname | Serves |
| -------- | ------ |
| `admin.example.com` | Admin UI, login, settings, gallery, API, upload |
| `img.example.com` | Image delivery, hotlinks, CDN |
Use **site / image hostname**, not “frontend / backend”. PicHost does **not** require `frontend :3000 + backend :6892` in production.
---
## Orange cloud & preferred edge
**DNS Proxied (orange cloud)** is normal and recommended:
```text
admin.example.com, img.example.com
A/CNAME → origin → Proxied
```
Opening `admin.example.com` should yield `Host: admin.example.com` at PicHost; `img.example.com` should yield `Host: img.example.com`.
**Preferred-edge** setups (alternate IPs, entry hostnames) are also fine. Preferred routing may change the path to Cloudflare’s edge, but must **not** rewrite the image hostname’s origin identity to the site hostname:
```text
admin → CF → admin.example.com → origin → PicHost
img → CF → img.example.com → origin → PicHost
```
---
## 6. Typical bad topologies
These patterns break Host isolation even when the browser still shows the image hostname:
### ❌ Bad 1: Image host fetches site host
```mermaid
flowchart LR
user[User on img.example.com]
worker[Pages / Worker]
admin[fetch admin.example.com]
app[PicHost]
user --> worker --> admin --> app
```
The last hop has `Host: admin.example.com`, so PicHost treats it as site traffic — `https://img.example.com/` may show the admin UI. This is **Host rewrite**, not a cache bug.
### ❌ Bad 2: Preferred proxy upstreams to site host
```text
img.example.com → preferred hostname → admin.example.com → origin
```
Even for routing only, using the site hostname as upstream breaks image isolation.
### ❌ Bad 3: Public exposure of port 6892
```text
any host / IP:6892 → PicHost
```
Users can bypass Cloudflare and Nginx `server_name` rules. Port `6892` should be reachable only from `127.0.0.1` or an internal reverse proxy.
### ✅ Correct pattern
```text
admin → admin → PicHost
img → img → PicHost
```
Do **not** use Pages / Workers as a **full-site reverse proxy** for PicHost (extra `pages.dev` / `workers.dev` entry points). PicHost needs `node-server`, SQLite, `sharp`, and local `data/` — it **cannot** run as a Node app on Pages/Workers. Keep Docker/VPS for the app; use orange-cloud DNS and optional R2 on CF.
---
## Workers / Pages guidance
If you only need headers, auth, Referer rules, logging, or cache — and PicHost remains your origin:
| Prefer | Avoid |
| ------ | ----- |
| `img.example.com` → **Worker Route** → existing `img` origin | `img` → Worker → `fetch(admin.example.com)` |
Cloudflare recommends [Routes](https://developers.cloudflare.com/workers/configuration/routing/routes/) when an external origin already exists, rather than [Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) used to chain cross-host `fetch()`.
Code like this on the image hostname is a smoking gun:
```js
fetch(`https://admin.example.com${url.pathname}`)
```
---
## Nginx / 1Panel layout
Two `server` blocks, same upstream. Critical line: `proxy_set_header Host $host;`
```nginx
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 12m;
}
}
server {
server_name img.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# Reject bare IP and unknown hostnames (strongly recommended)
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
return 444;
}
```
With orange cloud, restrict the origin firewall to **[Cloudflare IP ranges](https://www.cloudflare.com/ips/)** only.
---
## Two-layer Host protection
Use reverse proxy and application layers **together**:
| Layer | Role |
| ----- | ---- |
| **Nginx default server** | Reject bare IP, `pages.dev`, and other undeclared hostnames |
| **PicHost middleware (v1.2.x+)** | When dual-domain is on, unconfigured hosts get 404 at the app (`localhost` / `127.0.0.1` exempt in development) |
Even with app-layer blocking, keep a default server at the proxy to reduce junk traffic to PicHost.
---
## X-Forwarded-Host
PicHost may consult `X-Forwarded-Host` behind a reverse proxy, but that header is **forgeable** by clients. Therefore:
- Do not expose port `6892` publicly
- Only trusted proxies should reach the app
- Prefer keeping `Host` correct from the first hop
---
## Troubleshooting: admin UI on image hostname?
**Inspect the proxy chain first — do not purge CF cache first.**
Cache issues usually mean stale JS/CSS/images or occasional 404s. If `/`, `/login`, and `/settings` work **reliably** on the image host, the Host was almost certainly rewritten.
```text
① Browser hostname?
② Pages / Worker / preferred proxy in the middle?
③ What URL does the proxy fetch?
④ Does it fetch admin.example.com?
⑤ Which Host does 1Panel / PicHost see?
```
---
## Related
- [Dual-domain separation](./domain-separation.md)
- [Reverse proxy](./reverse-proxy.md)
- [Environment variables](./configuration.md)
- [FAQ](./faq.md)
+103
View File
@@ -0,0 +1,103 @@
# Environment variables
Configure PicHost via environment variables (Docker / `.env`) or the in-app **Settings** page. Most values prefer **SQLite** when saved; some env vars override or lock UI editing.
## Priority overview
| Setting | Priority (high → low) | Env overrides UI |
| ------- | --------------------- | ---------------- |
| API upload token | Env → SQLite | **Yes** (cannot regenerate in UI) |
| WebP quality | SQLite → env → default 80 | No |
| Referer whitelist | SQLite → env | No |
| Site / image base URL | SQLite → env (configured in API); links use `effective*` or request origin | No |
| Short image links | SQLite → env → default false | No |
| Date-based paths | SQLite → env → default true | No |
| Global auto-delete days | SQLite → env → default 0 | No |
## Variables
### `API_UPLOAD_TOKEN`
Token for external uploads (Twikoo, userscripts, blogs). Header: `Auth-Token: <token>`; form field `token` still works.
- Generate in **API** page when unset
- **When set in env, it wins** — UI shows “overridden by environment” and cannot regenerate
- Alias: `NUXT_API_UPLOAD_TOKEN`
### `ALLOWED_REFERER_HOSTS`
Comma-separated Referer whitelist. Own hostnames are allowed automatically. Optional; configurable in Settings.
### `SITE_BASE_URL` / `IMAGE_BASE_URL`
- **Site URL**: admin UI, API, Twikoo upload
- **Image URL**: copied links and public image URLs
Single-domain: set `IMAGE_BASE_URL` only or leave empty (request origin). Dual-domain: hostnames must differ — see [Dual-domain separation](./domain-separation.md) and [Cloudflare deployment](./cloudflare-deployment.md). Values must match proxy `server_name`; do not use `localhost`, IP, or unlisted hostnames for production admin access.
In **Settings**, enable dual-domain explicitly and fill both URLs; disabling clears the site URL (with confirmation). Configured values vs effective link URLs are separate in the API — see [Changelog](./changelog.md#1-2-2-2026-08-30).
### `HIDE_FOLDER_IN_URL`
`true`: short links (domain + filename only), e.g. `img.example.com/a8K3xP.webp`. `false`: keep date or custom folders, e.g. `img.example.com/2026/08/a8K3xP.webp`. Public URLs never expose the internal `images/` prefix; legacy `/images/...` paths still work.
### `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT`
- `true` or `STORAGE_LAYOUT=date` (default): `images/YYYY/MM/id.webp`
- `false` or `STORAGE_LAYOUT=flat`: `images/id.webp`
Also toggled in Settings.
### `DATA_DIR`
Root for images and SQLite. Default `./data`; `/data` in container.
### `WEBP_QUALITY`
Server WebP quality 1–100, default **80**.
### `AUTO_DELETE_DAYS`
Delete images older than N days globally; `0` disables. Affects **new uploads after enable** only. Also in Settings.
### `DEV_BYPASS_ACCESS`
Local dev only: `true` bypasses login. Never use in production.
### Storage backends (optional)
Admins usually add backends in **Storage** UI; env vars such as `STORAGE_BACKEND=s3` and `S3_*` are also supported. See [Storage](./storage.md).
## Docker Compose example
```yaml
services:
pichost:
image: muxui/pichost:latest
ports:
- "6892:6892"
volumes:
- ./data:/data
environment:
SITE_BASE_URL: https://admin.example.com
IMAGE_BASE_URL: https://pic.example.com
API_UPLOAD_TOKEN: your-secret-token
WEBP_QUALITY: "85"
```
Restart the container after changing env vars.
## Settings mapping
| In-app setting | Environment variable |
| -------------- | ---------------------- |
| API token | `API_UPLOAD_TOKEN` |
| Referer protection | `ALLOWED_REFERER_HOSTS` |
| Site URL | `SITE_BASE_URL` |
| Image URL | `IMAGE_BASE_URL` |
| Hide folder prefix | `HIDE_FOLDER_IN_URL` |
| Date-based paths | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
| WebP quality | `WEBP_QUALITY` |
| Auto-delete | `AUTO_DELETE_DAYS` |
Template: [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example) in the repo root.
+175
View File
@@ -0,0 +1,175 @@
# Dual-domain separation · Recommended deployment
When admin and image domains are split, the admin UI, API, and public image links use different hostnames. PicHost enforces isolation in **application middleware**: the image host only serves image paths; other requests return 404.
## Architecture
**One Docker instance + two domains**, both **fully reverse-proxied** to port `6892` (isolation is handled by PicHost middleware; no path splitting in Nginx).
| Domain | Purpose |
| ------ | ------- |
| `admin.example.com` | Admin UI, API, Twikoo upload |
| `pic.example.com` | Public image links (non-image paths blocked) |
Set during `/setup` or in **Settings**:
- **Site URL**: `https://admin.example.com`
- **Image URL**: `https://pic.example.com`
Setup and Settings **do not** auto-fill the site URL from `window.location.origin`; use manual input or “Use detected URL”. Disabling dual-domain requires confirmation and clears the site URL. Saves include `domainSeparation: true/false`. See [Changelog](./changelog.md#1-2-2-2026-08-30).
## Nginx example
```nginx
# admin.example.com — admin + API
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — same full proxy; PicHost middleware blocks non-image paths
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Default server: reject bare IP and unknown Host values (strongly recommended in production)
server {
listen 443 ssl default_server;
listen 80 default_server;
server_name _;
return 444;
}
```
> Caddy, NPM, and similar tools work the same: proxy both hostnames to `http://<host>:6892` and forward `Host` and `X-Forwarded-Proto`.
## Security notes
### Middleware isolation and third hosts
PicHost uses the request **Host** (and `X-Forwarded-Host` when proxied) to decide site vs image domain:
- **Image host**: non-image paths (e.g. `/`, `/settings`) → 404
- **Site host**: image URL paths (e.g. `/images/...`) → 404
- **Unconfigured third hosts** (e.g. `*.pages.dev`, bare IP, old image domains): **from v1.2.x the app returns 404 for all methods** (`localhost` / `127.0.0.1` are exempt in development for `npm run dev`)
This is independent of hyphenated or long domain names. Older versions did not block third hosts; rely on a reverse-proxy default server as a complement in production.
**Production recommendations:**
1. Configure `server_name` only for the site and image hosts; add a **default server** to reject other Host values and bare IP access (see Nginx example above; complements app-layer blocking)
2. With Cloudflare **orange-cloud** DNS, restrict the origin firewall to **[Cloudflare IP ranges](https://www.cloudflare.com/ips/)** so traffic cannot bypass the CDN
3. Sign in only on the **site hostname** from Settings; do not mix `localhost`, IP, or unlisted domains
4. Do **not** put Cloudflare Pages / Workers in front of PicHost as a **full-site reverse proxy** (`IMAGE_BASE_URL` may point at a CDN URL, but the app itself should run on Docker/VPS)
5. If you must proxy at the edge, preserve the client **`Host` or `X-Forwarded-Host`** or host isolation will fail
6. PicHost **cannot** run as-is on Pages/Workers (needs `node-server`, SQLite, `sharp`, local `data/`). Keep Docker/VPS; use orange-cloud DNS and optional R2 storage on CF
### Cloudflare orange cloud (incl. preferred edge IPs)
Full guide: [Cloudflare deployment](./cloudflare-deployment.md).
**Recommended:** both `admin.example.com` and `pic.example.com` **DNS Proxied** → origin Nginx / 1Panel → port `6892`, SSL **Full (strict)**.
| Feature | Notes |
| ------- | ----- |
| Orange-cloud CDN | Compatible with dual-domain; preserve `Host` and `X-Forwarded-Proto` on origin |
| DNS “preferred IP” setups | Only changes which CF edge IP clients hit; origin identity must stay **admin → admin**, **img → img** |
| R2 storage | Add an R2 backend under **Storage**; independent of orange cloud |
| `cloudflare` Git branch | R2-focused deployment line; still Docker, **not** Workers hosting |
**Do not confuse** “Create Worker” in the CF dashboard (Git + `wrangler deploy`) with deploying PicHost — the repo uses `nitro.preset: 'node-server'` and is not Workers-ready.
**Do not** chain the image hostname to `fetch(admin…)` via Pages/Workers — that rewrites Host and breaks isolation ([bad topologies](./cloudflare-deployment.md#6-typical-bad-topologies)).
See [Reverse proxy](./reverse-proxy.md#cloudflare-orange-cloud) for origin hardening.
## FN NAS · Lucky reverse proxy
In [Lucky](https://github.com/gdy666/lucky), create one **reverse proxy** rule per domain. **Frontend** is the public hostname; **backend** is PicHost on your LAN (e.g. `http://192.168.8.3:6892`).
**Site domain (admin)**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `admin.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
**Image domain**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `image.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
Both rules can point to the same backend; PicHost applies host-based isolation automatically.
## Environment variables (optional)
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
See [Environment variables](./configuration.md).
## Local testing (Windows / macOS)
### 1. Edit hosts
```text
127.0.0.1 admin.pichost.test
127.0.0.1 pic.pichost.test
```
Prefer the `.test` TLD; avoid `.local` where it conflicts with mDNS.
### 2. Start dev server
```bash
npm run dev
```
Dev binds `127.0.0.1:3000` by default. If the hostname resolves but the browser cannot connect, check whether dev is listening on IPv6 `::1` only.
### 3. Configure dual domains
| Field | Example |
| ----- | ------- |
| Site URL | `http://admin.pichost.test:3000` |
| Image URL | `http://pic.pichost.test:3000` |
Always open admin on the **site hostname**, not `localhost` (cookies and Host must match).
### 4. Expected behavior
| URL | Expected |
| --- | -------- |
| `http://admin.pichost.test:3000/` | Admin UI works |
| `http://admin.pichost.test:3000/images/...` or `/2026/08/xxx.webp` | **404** (use image host) |
| `http://pic.pichost.test:3000/` | **404** (image host has no admin) |
| `http://pic.pichost.test:3000/images/...` | Image served |
| `http://localhost:3000/` or `http://127.0.0.1:3000/` | **Admin still loads** (development exception; block unconfigured hosts at proxy in production) |
After upload, copied links should use `pic.pichost.test`; gallery thumbnails should load.
## Notes
- Hostnames must **differ** (same machine, different subdomains is fine)
- Site / image URLs in Settings must **exactly match** proxy `server_name` (including `www` or not)
- No separate static root on the image domain; unlike EasyImages, PicHost isolates in-app
- Single-domain setups can skip separation and use `IMAGE_BASE_URL` or the request host
- When Referer protection is enabled, site and image hosts are whitelisted automatically
For single-domain proxy basics see [Reverse proxy](./reverse-proxy.md). Chinese version: [双域名分离](/guide/domain-separation)
+77
View File
@@ -0,0 +1,77 @@
# FAQ
## Forgot the admin password?
With `docker exec` access:
```bash
docker exec pichost reset-password
docker exec pichost reset-password <username>
```
Local: `npm run reset-password`. See [Quick start](./getting-started.md#forgot-password).
## Why can’t I regenerate the API token in the UI?
`API_UPLOAD_TOKEN` in the environment **locks** the token. Change env and **restart**. See [Environment variables](./configuration.md#api-upload-token).
## Hotlink / Referer errors?
Check the **Referer whitelist** (`ALLOWED_REFERER_HOSTS` or Settings). Referring sites must be listed; PicHost’s own hosts and dual-domain pair are allowed automatically.
## Why can admin load on pages.dev / workers.dev / IP with dual-domain?
**From v1.2.2:** when dual-domain is on, PicHost returns **404** for Host values **other than** the configured site and image hostnames (`localhost` / `127.0.0.1` exempt in development). See [Changelog](./changelog.md#1-2-2-2026-08-30).
If admin still loads:
1. **Dual-domain not active** (image URL set but site URL empty) — neither path split nor third-host blocking applies
2. **Proxy rewrote Host to site hostname** — e.g. Pages/Worker `fetch(admin…)` on the image hostname
3. **Port `6892` exposed publicly** — bypasses Cloudflare and Nginx `server_name`
Fix: configure both URLs in Settings; add a default server block; with orange cloud, allow only CF IPs on origin; do not full-proxy through Pages/Workers. See [Dual-domain separation](./domain-separation.md#middleware-isolation-and-third-hosts), [Cloudflare deployment](./cloudflare-deployment.md), and [Changelog](./changelog.md#1-2-2-2026-08-30).
## Can I deploy PicHost on Cloudflare Workers / Pages?
**Not with the current codebase.** PicHost needs `node-server`, SQLite, `sharp`, and local `data/` — use Docker/VPS. Connecting the repo in “Create Worker” will not deploy successfully.
Recommended: run PicHost on origin; use **proxied DNS** (and optional **R2** storage). Do not full-proxy the app through Pages/Workers.
## Dual-domain: admin works but thumbnails break?
- Verify **IMAGE_BASE_URL** and proxy to the same instance
- Always open admin on the **site hostname**, not mixed with `localhost`
- See [Dual-domain separation](./domain-separation.md)
## Undeletable gallery items after v1.2 upgrade?
Likely **orphan index** rows (no file on disk). Startup sync removes them on restart. See [v1.2 migration](./migration.md#step-2-startup-index-sync-automatic).
## Do I still need `data/blog/` after migration?
After `migrate --apply`, files live under `data/images/blog/`. Remove leftover empty dirs after backup if you like; dirs with non-image files are kept by the CLI.
## Object storage only — run migrate?
No. Skip the CLI; startup sync focuses on local `data/images/` scanning.
## Replace old image URLs in posts?
Use `data/mapping.json` from `migrate` preview. Keys are **storage paths**, not full legacy URLs. See [Migration](./migration.md#urls).
## Skip login during development?
`DEV_BYPASS_ACCESS=true` (local only). Use `/setup` for normal flows.
## Docs vs README?
This **VitePress site** is the full guide; README is a short overview. Online: <https://o96u.github.io/PicHost/>
## What should the GitHub repo About say?
For **main**:
- **Description**: `Lightweight self-hosted image hosting — Docker, multi-user, gallery & API, local disk or S3-compatible storage.`
- **Website**: `https://o96u.github.io/PicHost/` (docs, not a live demo)
Do not claim the app runs on Cloudflare Pages/Workers. The **cloudflare** branch demo is [pic.roven.cc](https://pic.roven.cc).
+72
View File
@@ -0,0 +1,72 @@
# Quick start
PicHost is a self-hosted image host for NAS, home servers, or VPS. Docker is recommended; the first visit runs a web wizard to create the admin account.
## Try it online
| Branch | Notes |
| ------ | ----- |
| **main** | No public demo — see [screenshots](./index.md#screenshots) or the repository README |
| **cloudflare** | R2-only live demo: [pic.roven.cc](https://pic.roven.cc) |
## Docker (recommended)
```bash
docker run -d \
--name pichost \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
```
Default port: **6892**. Open `http://<host>:6892` and complete setup.
If you cloned the repo:
```bash
docker compose up -d
```
See `docker-compose.yml` in the repository.
## First-time setup
1. Open your instance URL (Docker default `http://<host>:6892`).
2. Complete `/setup` with admin username and password.
3. (Optional) Configure site URL, image URL, Referer rules — see [Environment variables](./configuration.md) and [Dual-domain separation](./domain-separation.md).
4. Upload from the home page; admins can open **Storage**, **Settings**, and **API**.
## Sign in
Management pages (upload, stats, settings, etc.) require login. Enter username and password, **drag the slider to align with the gap**, then click **Sign in**.
![Login](/screenshots/login.png)
If registration is enabled, create an account from the register page. Legacy deployments may still use a secret key (see the on-screen hint).
## Forgot password
Requires `docker exec` (server access):
```bash
docker exec pichost reset-password # admin only when there is exactly one
docker exec pichost reset-password <username> # admin or regular user
```
A random password is printed. Change it after login.
Local dev: `npm run reset-password` or `npm run reset-password -- <username>`
## Upgrading to v1.2.0
If `data/` has legacy top-level folders next to `images` (e.g. `blog/`, `twikoo/`), read the [v1.2 migration guide](./migration.md) first.
Object-storage-only or files already under `data/images/` — pull the new image and restart.
## Next steps
- [Local development](./local-dev.md)
- [Environment variables](./configuration.md)
- [Storage](./storage.md)
- [API](./api.md)
+40
View File
@@ -0,0 +1,40 @@
# PicHost documentation
Welcome to the PicHost user guide. This site is the single source of truth; the repository README keeps a short overview and quick start.
## Suggested reading order
1. [Quick start](./getting-started.md) — Docker and first-time setup
2. [Environment variables](./configuration.md) — Production config vs in-app settings
3. [Changelog](./changelog.md) — Version history and upgrade notes
4. [Storage](./storage.md) — Backends and URL modes
5. [API](./api.md) — REST endpoints and tokens
## Screenshots
**main** has no public demo. Below are v1.2.0 product screenshots.
| First-time setup | Login |
| :--------------: | :---: |
| ![Setup](/screenshots/setup.png) | ![Login](/screenshots/login.png) |
| API | Stats & gallery |
| :-: | :-------------: |
| ![API](/screenshots/api.png) | ![Stats & gallery](/screenshots/stats.png) |
| Storage | Settings |
| :-----: | :------: |
| ![Storage](/screenshots/storage.png) | ![Settings](/screenshots/settings-en.png) |
| Activity log |
| :----------: |
| ![Activity log](/screenshots/logs.png) |
## Branches
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) | Full product; screenshots above |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | R2-only; live demo [pic.roven.cc](https://pic.roven.cc) |
Use **main** for multiple cloud backends; use **cloudflare** if you only deploy with Cloudflare R2.
+54
View File
@@ -0,0 +1,54 @@
# Local development
Full-stack Nuxt 4 development at the repository root. Data defaults to `./data`.
## Requirements
- Node.js **22**
- npm **11** (see `packageManager` in `package.json`)
## Start
```bash
npm install
cp .env.example .env # optional
npm run dev
```
Open `http://localhost:3000/setup` to create the admin account. Data directory defaults to `./data` (`DATA_DIR` overrides).
## Common commands
| Command | Description |
| ------- | ----------- |
| `npm run dev` | Dev server |
| `npm run build` | Production build |
| `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) |
## Dev-only variables
| Variable | Description |
| -------- | ----------- |
| `DEV_BYPASS_ACCESS` | `true` bypasses login (skips `/setup`; rarely needed) |
| `DATA_DIR` | Data directory, default `./data` |
Full list: [Environment variables](./configuration.md).
## Dual-domain local test
Edit `hosts` and follow [Dual-domain separation](./domain-separation.md) for local verification.
## Documentation site
```bash
npm run docs:dev
npm run docs:build
npm run docs:preview
```
Sources live in `docs-site/`; screenshot sources are in `docs/screenshots/` (including `login.png`), copied to `docs-site/public/screenshots/` before build. Live docs: <https://o96u.github.io/PicHost/>
+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: [v1.2 迁移指南](/guide/migration)
+83
View File
@@ -0,0 +1,83 @@
# Reverse proxy
In production, terminate HTTPS with Nginx, Caddy, NPM (Nginx Proxy Manager), or your NAS proxy and forward to PicHost on port **6892**.
## Basics
- Upstream: `http://127.0.0.1:6892` (or host IP)
- Forward: `Host`, `X-Forwarded-Proto` (and `X-Forwarded-For` as needed)
- Body size: Nginx `client_max_body_size 12m` or larger
- Use **HTTPS** on the public edge
## Single domain (Nginx)
Admin, API, and images on one hostname:
```nginx
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
```
Optional:
```env
IMAGE_BASE_URL=https://pic.example.com
```
Without `IMAGE_BASE_URL`, PicHost infers the public URL from forwarded headers.
## Dual domains
Both hostnames can fully proxy to the same port; isolation is enforced in PicHost middleware.
See [Dual-domain separation](./domain-separation.md).
## Cloudflare orange cloud
See [Cloudflare deployment](./cloudflare-deployment.md).
| Practice | Notes |
| -------- | ----- |
| **Proxied DNS** | Both admin and image hostnames → origin proxy to `6892`, SSL **Full (strict)** |
| Preferred edge IPs | Compatible; keep **admin → admin**, **img → img** on origin |
| **R2** | Object storage backend in **Storage**; not the same as hosting the app on Pages |
| Origin firewall | Allow only [Cloudflare IPs](https://www.cloudflare.com/ips/); do not expose `6892` publicly |
**Avoid:**
- **Cloudflare Pages** (`*.pages.dev`) or **Workers** (`*.workers.dev`) as a **full-site reverse proxy**, or chaining the image host to `fetch(admin…)` — rewrites Host and bypasses dual-domain isolation ([guide](./cloudflare-deployment.md))
- “Create Worker” from Git on this repo — `node-server` preset is not Workers-ready
## Origin hardening (dual-domain)
PicHost middleware does **not** reject unlisted Host values. Complement with:
1. **`server_name` allowlist** — site + image hosts only; `default_server` blocks bare IP and unknown Host
2. **Do not expose 6892 on the public Internet** — only local reverse proxy; public 80/443 only
3. **Orange cloud** — origin firewall limited to Cloudflare IP ranges
## Caddy
```caddy
pic.example.com {
reverse_proxy 127.0.0.1:6892
}
```
Add a second site block for the second hostname with the same upstream.
## Caching
Image 404/403 and domain-isolation 404 responses are **no-cache** to avoid sticky CDN errors. Be careful caching API paths behind a CDN.
## See also
- [Environment variables](./configuration.md)
- [Quick start](./getting-started.md)
+66
View File
@@ -0,0 +1,66 @@
# Storage
PicHost supports **local disk** and **S3-compatible object storage** (Cloudflare R2, Tencent COS, Alibaba OSS, AWS S3, etc.). Admins manage backends at **Storage** (`/storage`); new uploads go to the **default** backend.
![Storage management](/screenshots/storage.png)
## Layout (local)
```
/data
├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
└── images/ # All files when using local backend
```
- New uploads: `images/randomId.webp` or `images/YYYY/MM/randomId.webp` (flat mode in Settings)
- Migrated legacy: `images/blog/...` and similar
- With cloud backends, blobs live in the bucket; index stays in SQLite `images`
All **keys** start with `images/`. The upload API does **not** accept a `folder` parameter.
## Adding a backend
1. Sign in as admin → **Storage** → **Add backend**.
2. Choose **local disk** or **S3-compatible** (R2 / COS / OSS presets).
3. Enter connection details (bucket, endpoint, keys).
4. **Set as default** so new uploads use it.
### Cloudflare R2
| Field | Value |
| ----- | ----- |
| Endpoint | `https://<account_id>.r2.cloudflarestorage.com` |
| Region | `auto` |
| Bucket | Your R2 bucket name |
| Keys | R2 API token access / secret |
The **cloudflare** branch streamlines R2-only deploys; see branch notes on the [docs overview](./index.md).
### Environment variables (optional)
Some installs seed a default S3 backend via `STORAGE_BACKEND=s3`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`. **UI-configured backends live in SQLite** alongside env-based defaults.
## URL modes
| Mode | Behavior |
| ---- | -------- |
| **proxy** | Serve via PicHost (`GET /images/...`) |
| **public** | 302 redirect to bucket/CDN public URL |
Copied links use **IMAGE_BASE_URL**. “Hide folder prefix” may shorten URLs; the server resolves by path or basename.
## Usage & gallery filter
- Per-backend usage on the Storage page
- Gallery filter by backend (admin)
## Backup
Back up `data/images/`, `data/pichost.db`, and bucket objects.
Upgrading from v1.1.x with parallel folders: [v1.2 migration](./migration.md).
## See also
- [Environment variables](./configuration.md)
- [Users & permissions](./users-and-permissions.md)
+49
View File
@@ -0,0 +1,49 @@
# Twikoo
PicHost supports **Twikoo** and **EasyImage 2.0** image upload — configure your comment system like EasyImage.
## Twikoo settings
| Setting | Value |
| ------- | ----- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://your-domain/api/index.php` |
| `IMAGE_CDN_TOKEN` | Same as global `API_UPLOAD_TOKEN` |
- Use your **site hostname** (admin domain) in `IMAGE_CDN_URL`
- Generate the token in PicHost **API** or set `API_UPLOAD_TOKEN`
## Upload endpoint
`POST /api/index.php`
| Field | Description |
| ----- | ----------- |
| `image` | Image file |
| `token` | Global API token (same as `Auth-Token`) |
```bash
curl -X POST "https://admin.example.com/api/index.php" \
-F "token=YOUR_TOKEN" \
-F "image=@./demo.png"
```
Files are stored under `images/` and owned by the **global token** account (admin).
## REST vs Twikoo
| Aspect | REST `/api/images/upload` | Twikoo `/api/index.php` |
| ------ | ------------------------- | ------------------------ |
| Auth | `Auth-Token` header | form `token` |
| Response | PicHost JSON | EasyImage-compatible JSON |
| Token | Global or personal | Global only |
## Dual-domain
Point Twikoo uploads at **SITE_BASE_URL**; public images use **IMAGE_BASE_URL**. See [Dual-domain separation](./domain-separation.md).
## See also
- [API](./api.md)
- [Users & permissions](./users-and-permissions.md)
- [Environment variables](./configuration.md)
@@ -0,0 +1,58 @@
# Users & permissions
PicHost uses role-based access control (RBAC): **admin** and **user**. Enforcement is server-side.
## Role capabilities
| Capability | Admin | User |
| ---------- | :---: | :--: |
| Upload images | ✓ | ✓ |
| Own gallery | ✓ | ✓ |
| All galleries / uploader badges | ✓ | — |
| Storage management (`/storage`) | ✓ | — |
| System settings (`/settings`) | ✓ | — |
| Allow registration | ✓ | — |
| Global API token | ✓ | — |
| Personal API token | ✓ | ✓ |
| Stats (incl. user count) | ✓ | partial |
| Activity log | ✓ | — |
Regular users see **API**, **Stats** in the nav; user menu: **Change password**, **Sign out**.
## Auth scenarios
| Scenario | Auth | Ownership / visibility |
| -------- | ---- | ---------------------- |
| Web upload | Session cookie | Current user |
| API upload + **user token** | `Auth-Token` header | Token owner |
| API upload + **global token** | `Auth-Token` header | Admin (`userId` null) |
| Twikoo `POST /api/index.php` | form `token` | Same as global token |
| Gallery list / search / delete | Session or token | Users: own only; admin: all |
| Direct link `GET /images/...` | None (Referer rules) | Public if URL is known |
## Accounts
- By default only admins create users; **allow registration** is an admin setting
- Passwords hashed with scrypt
- Web login requires a slider captcha (align the thumb with the gap)
- Login rate limiting
## Upload preferences & auto-delete
- **Upload preferences** (home card flip): client compression, auto-copy links, per user
- **Auto-delete**: global (admin) and per-user policies; affects **new uploads after enable** only
## API tokens
| Type | Managed by | Upload owner | Typical use |
| ---- | ---------- | ------------ | ----------- |
| **Global** | Admin (API page) | Admin account | Twikoo, site-wide scripts |
| **Personal** | Each user (API page) | That user | Personal blog, private scripts |
`API_UPLOAD_TOKEN` in env locks the global token — see [Environment variables](./configuration.md).
## See also
- [API](./api.md)
- [Twikoo](./twikoo.md)
- [Storage](./storage.md)
+32
View File
@@ -0,0 +1,32 @@
---
layout: home
hero:
name: PicHost
text: Lightweight image hosting
tagline: Self-hosted · Multi-user · Docker · API · Twikoo · Local disk or object storage
actions:
- theme: brand
text: Quick start
link: /en/guide/getting-started
- theme: alt
text: API reference
link: /en/guide/api
- theme: alt
text: GitHub
link: https://github.com/O96u/PicHost
features:
- title: Drag, click, paste
details: Server-side WebP compression and Referer hotlink protection.
- title: Multi-backend storage
details: Local disk plus S3-compatible backends (R2 / COS / OSS / AWS); proxy or public URLs.
- title: Users & RBAC
details: Admin and regular users; global and per-user API tokens; gallery scoped by owner.
- title: Zero-config Docker
details: First-run web wizard; reset passwords with `docker exec pichost reset-password`.
- title: Dual-domain separation
details: One instance, two hostnames, full reverse proxy; middleware enforces isolation.
- title: Twikoo compatible
details: "POST /api/index.php follows EasyImage 2.0 for comment systems."
---
+99
View File
@@ -0,0 +1,99 @@
# API
PicHost 提供 REST 接口与 Twikoo 兼容端点。部署后登录 **API** 页可查看当前 Token 与可复制 cURL 示例。
## 鉴权
所有 REST 接口使用请求头:
```http
Auth-Token: YOUR_TOKEN
```
| Token 类型 | 获取方式 | 说明 |
| ---------- | -------- | ---- |
| **全局 Token** | 管理员 **API** 页生成;或 `API_UPLOAD_TOKEN` 环境变量 | 环境变量优先且锁定后台重新生成 |
| **个人 Token** | 各用户 **API** 页 | 仅用于该用户脚本,上传归属本人 |
表单上传仍可使用字段 `token`(Twikoo 协议)。
## 接口列表
### 1. 上传图片
`POST /api/images/upload`
上传单张或多张图片。字段 `image` 传图片;仍兼容 `file`、`files`。存入 `images/`。
```bash
curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png"
```
### 2. 获取图片列表
`GET /api/images`
分页列出图库。`limit` 默认 20、最大 100。
```bash
curl "https://admin.example.com/api/images?limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 3. 搜索图片
`GET /api/images/search`
按文件名或路径关键词搜索,参数 `q` 必填。
```bash
curl "https://admin.example.com/api/images/search?q=demo&limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 4. 删除图片
`DELETE /api/images`
通过 `key` 删除单张,`key` 为存储路径(如 `images/2026/08/xxx.webp`)。
```bash
curl -X DELETE "https://admin.example.com/api/images?key=images/2026/08/xxxx.webp" \
-H "Auth-Token: YOUR_TOKEN"
```
### 5. 批量删除
`POST /api/images/batch-delete`
请求体 JSON:`{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}`
```bash
curl -X POST "https://admin.example.com/api/images/batch-delete" \
-H "Auth-Token: YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'
```
## 错误格式
REST 接口返回 JSON,含 `code` 与 `message`(如 `UNAUTHORIZED`、`FORBIDDEN`、`INVALID_REQUEST`)。Twikoo 端点使用 EasyImage 兼容 JSON 格式。
## 权限与可见范围
- 普通用户 Token:列表/搜索/删除仅作用于自己的图片
- 管理员 Token:可访问全部图片
- 全局 Token 上传的图片归属管理员
详见 [用户与权限](./users-and-permissions.md)。
## Twikoo
`POST /api/index.php` 见 [Twikoo](./twikoo.md)。
## 相关
- [环境变量](./configuration.md) — `API_UPLOAD_TOKEN`
- [反向代理](./reverse-proxy.md) — 公网 URL 与 HTTPS
+121
View File
@@ -0,0 +1,121 @@
# 更新日志
PicHost 版本历史与重要变更说明。完整记录亦见仓库根目录 [CHANGELOG.md](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md)。
---
## [1.2.3] — 2026-08-30
### 修复
- **简短图片链接 + 按年/月分组**:纯文件名直链(如 `https://域名/xxx.webp`)不再误当成 `images/xxx.webp`,改为经 SQLite 索引反查 `images/年/月/xxx.webp`,缩略图与短链恢复正常
---
## [1.2.2] — 2026-08-30
> 升级后请**重启服务**使中间件与 API 生效。
### 双域名配置与 Host 隔离
#### 修复
| 问题 | 说明 |
| ---- | ---- |
| 管理域被误清空 | 设置页保存时,关闭双域名或仅改图片域可能把 `site_base_url` 写成空字符串,导致 UI 切回单域、管理域「消失」 |
| 配置与生效 URL 混淆 | `GET /api/settings` 的 `siteBaseUrl` / `imageBaseUrl` 曾混入当前请求的 `origin`,设置页「当前生效」与数据库配置难以区分 |
| 第三 Host 绕过隔离 | `pages.dev`、裸 IP、旧图片域等未纳入配置的 Host 此前不被中间件拦截,后台/API 可能仍可访问 |
#### 改进
**设置 API(`GET /api/settings`)**
| 字段 | 含义 |
| ---- | ---- |
| `siteBaseUrl` / `imageBaseUrl` | **配置值**(DB → 环境变量),不含 request fallback |
| `effectiveSiteBaseUrl` / `effectiveImageBaseUrl` | **直链生成用**,未配置时回退到当前请求 origin |
| `runtime.currentOrigin` | 检测到的当前访问地址(仅展示,不写入配置) |
| `runtime.hostRole` | `site` / `image` / `unknown` / `single` |
**保存 API(`PATCH /api/settings`)**
- 新增 body 字段 `domainSeparation`(`true` / `false`),显式表达是否启用双域名
- `domainSeparation: true` 时网站域与图片域**均必填**,且主机名不能相同
- `domainSeparation: false` 时允许清空网站域
- 未传 `domainSeparation` 且双域名已配置时,**禁止**单独把 `siteBaseUrl` 置空
**设置页 / 首次设置**
- 表单回填配置值;「当前生效」显示 `effective*` 字段
- 展示「检测到当前访问」地址,**不再**自动写入 `window.location.origin`
- 提供「填入检测地址」按钮(用户主动点击才填)
- 关闭双域名需确认弹窗
- 双域名开启且 `hostRole === 'unknown'` 时显示反代绕过警告
**Host 中间件**
- 双域名开启时,非 site/image 的 Host **应用层一律 404**(所有 HTTP 方法)
- 开发环境 `localhost` / `127.0.0.1` 例外,便于 `npm run dev`
- 与 Nginx `default_server` **互补**,建议两层都配置
#### 文档
- 重写 [Cloudflare / CF 优选部署](./cloudflare-deployment.md)(精简结构、检查清单、双层防护)
- 更新 [双域名分离](./domain-separation.md) 第三 Host 与应用层拦截说明
- 新增本页 **更新日志**
#### 升级说明
- **无需**数据库迁移或 CLI 操作
- 若管理域此前已被误清空:在 **设置** 中重新填写网站域,或设置环境变量 `SITE_BASE_URL` 后重启
- 生产双域名部署:确认反代 `server_name` 与设置一致;建议保留 Nginx default server
---
## [1.2.1] — 2026-08-29
### 新增
- 登录滑动拼图验证(缺口对齐);验证与登录分两步
- VitePress 文档站(`docs-site/`),部署至 GitHub Pages
- `npm run docs:dev` / `docs:build` / `docs:preview`
### 修复
- 统计页删除图片后图库不刷新
- 分页跳转输入框在 `type="number"` 下无法跳转
- 非首页路由下复制链接格式偏好不持久化
- 滑动验证 UI:滑块垂直居中、缺口尺寸与滑块一致
### 改进
- README 精简,详细说明迁入文档站
- 双域名文档补充未知 Host 风险与 Cloudflare 部署说明
---
## [1.2.0] — 2026-08-28
### 变更
- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移
### 新增
- CLI:`docker exec pichost migrate` / `migrate --apply`
- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录)
详见 [v1.2 迁移](./migration.md)。
---
## [1.1.x] 摘要
| 版本 | 要点 |
| ---- | ---- |
| 1.1.5 | API 上传后预览偶发失败修复;出图前校验文件存在 |
| 1.1.4 | 按年/月分组存储;网站域禁止直链出图 |
| 1.1.3 | 双域名分离(`SITE_BASE_URL` / `IMAGE_BASE_URL`);Host 中间件 |
| 1.1.0 | 多存储后端(S3 / R2 等) |
更早版本见 [GitHub CHANGELOG](https://github.com/O96u/PicHost/blob/main/CHANGELOG.md)。
+240
View File
@@ -0,0 +1,240 @@
# Cloudflare / CF 优选部署说明
PicHost 双域名模式下,应用根据请求 **Host**(及可信反代转发的 `X-Forwarded-Host`)区分**管理域**与**图片域**。使用 Cloudflare 橙云、优选线路、Workers 或 Pages 时,只要最终到达 PicHost 的 Host 身份不变,即可正常工作。
> **一句话原则:** 网络线路可以改变,请求的域名身份不能改变 — `admin → admin`,`img → img`,不要 `img → admin`。
基础概念见 [双域名分离](./domain-separation.md);反代通用要求见 [反向代理](./reverse-proxy.md)。
---
## 快速检查清单
部署或排错时,按顺序确认:
| # | 检查项 | 期望 |
| - | ------ | ---- |
| 1 | 浏览器地址栏域名 | `admin.*` 或 `img.*`,不要用 IP / `pages.dev` 混用 |
| 2 | 中间是否经过 Pages / Worker | 图片域**不得** `fetch(admin…)` |
| 3 | 反代 upstream | 两个域名均指向 `http://127.0.0.1:6892` |
| 4 | Nginx `proxy_set_header Host` | `$host`(保持客户端域名) |
| 5 | PicHost 设置 | `SITE_BASE_URL` / `IMAGE_BASE_URL` 与 `server_name` 一致 |
| 6 | 端口 `6892` | 仅本机或内网反代可达,不对公网裸露 |
---
## 推荐拓扑
两个域名经 Cloudflare(橙云)后,**各自独立**反代到同一 PicHost 实例:
```mermaid
flowchart TB
internet[Internet]
cf[Cloudflare 橙云]
admin[admin.example.com]
img[img.example.com]
panel[1Panel / Nginx]
app[PicHost :6892]
internet --> cf
cf --> admin
cf --> img
admin --> panel
img --> panel
panel --> app
```
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://img.example.com
```
```text
admin.example.com → http://127.0.0.1:6892
img.example.com → http://127.0.0.1:6892
```
不需要为「前端 / 后端 / 图片」开不同端口;一个 PicHost 实例、一个内部端口(默认 `6892`)即可。
### 管理域 ≠ 前端域名
| 域名 | 用途 |
| ---- | ---- |
| `admin.example.com` | 后台、登录、设置、图库、API、上传 |
| `img.example.com` | 图片直链、外链、CDN 分发 |
更准确的说法是**管理域 / 图片域**,而非「前端 / 后端」。PicHost 生产环境也**不需要** `前端 :3000 + 后端 :6892` 这种拆分。
---
## Cloudflare 橙云与 CF 优选
**橙云(DNS Proxied)** 是正常且推荐的方式:
```text
admin.example.com、img.example.com
A / CNAME → 源站 → Proxied
```
用户访问 `admin.example.com` 时,PicHost 应收到 `Host: admin.example.com`;访问 `img.example.com` 时应收到 `Host: img.example.com`。
**CF 优选**(优选 IP、优选域名、边缘入口)同样可用。优选可以改变用户到 Cloudflare 边缘的路径,但**不能**把图片域的回源身份变成管理域。两个域名应保持平行、独立的回源链:
```text
admin → CF → admin.example.com → 源站 → PicHost
img → CF → img.example.com → 源站 → PicHost
```
---
## 六、典型错误拓扑
以下方案会破坏 Host 隔离,即使浏览器地址栏仍显示图片域:
### ❌ 错误 1:图片域 fetch 到管理域
```mermaid
flowchart LR
user[用户 img.example.com]
worker[Pages / Worker]
admin[fetch admin.example.com]
app[PicHost]
user --> worker --> admin --> app
```
最后一跳 Host 变成 `admin.example.com`,PicHost 会认为这是管理域 — `https://img.example.com/` 可能看到后台。这是 **Host 被改写**,不是缓存问题。
### ❌ 错误 2:优选回源到管理域
```text
img.example.com → 优选域名 → admin.example.com → 源站
```
即使只为改善线路,只要 upstream 使用管理域,图片域隔离即失效。
### ❌ 错误 3:公网直接暴露 6892
```text
任意域名 / IP:6892 → PicHost
```
用户可绕开 Cloudflare 与 Nginx 的 `server_name` 限制。`6892` 应仅允许 `127.0.0.1` 或内网反代访问。
### ✅ 正确做法
```text
admin → admin → PicHost
img → img → PicHost
```
**不要** Pages / Workers **整站反代** PicHost(会多出 `pages.dev` / `workers.dev` 等第三入口)。PicHost 依赖 `node-server`、SQLite、`sharp` 与本地 `data/`,**不能**作为 Node 应用直接部署到 Pages/Workers;应用本体继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 即可。
---
## Workers / Pages 使用建议
若只需改响应头、鉴权、防盗链、日志或缓存,且源站仍是自己的 PicHost:
| 推荐 | 不推荐 |
| ---- | ------ |
| `img.example.com` → **Worker Route** → 原 `img` 源站 | `img` → Worker → `fetch(admin.example.com)` |
Cloudflare 对已有外部源站的场景更推荐 [Routes](https://developers.cloudflare.com/workers/configuration/routing/routes/)(在现有 proxied 域名与源站之前运行),而非用 [Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) 把 Worker 当作跨域 fetch 的中转。
Worker 中若出现:
```js
fetch(`https://admin.example.com${url.pathname}`)
```
即基本可确定隔离被破坏。
---
## Nginx / 1Panel 配置
两个 `server` 块,upstream 相同,**关键行**是 `proxy_set_header Host $host;`:
```nginx
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 12m;
}
}
server {
server_name img.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# 拒绝裸 IP 与未配置域名(强烈建议)
server {
listen 80 default_server;
listen 443 ssl default_server;
server_name _;
return 444;
}
```
橙云部署时,源站防火墙建议**仅放行 [Cloudflare IP 段](https://www.cloudflare.com/ips/)**,避免绕过 CDN 直连。
---
## 双层 Host 防护
生产环境建议反代层与应用层**互补**,而非二选一:
| 层级 | 作用 |
| ---- | ---- |
| **Nginx default server** | 拒绝裸 IP、`pages.dev` 等未在 `server_name` 中声明的 Host |
| **PicHost 中间件(v1.2.x+)** | 双域名开启时,非 site/image 的 Host 应用层返回 404(开发环境 `localhost` / `127.0.0.1` 例外) |
即使应用层已拦截第三 Host,仍建议在反代配置 default server — 减少无效流量到达 PicHost。
---
## X-Forwarded-Host 说明
PicHost 在反代场景会参考 `X-Forwarded-Host` 识别请求域名,但**不能无条件信任**该 Header(客户端可伪造)。因此:
- 不要让 `6892` 对公网裸露
- 确保只有可信反代能到达应用
- 首选方案仍是让 `Host` 从第一跳起就保持正确
---
## 排查:图片域能看到后台?
**先查反代链,不要先清 CF 缓存。**
缓存问题通常表现为旧 JS/CSS/图片或偶发 404;若图片域能**稳定**访问 `/`、`/login`、`/settings`,几乎都是 Host 被改写。
```text
① 浏览器实际域名?
② 是否经过 Pages / Worker / 优选反代?
③ 代理最终 fetch 的 URL?
④ 是否 fetch 到 admin.example.com?
⑤ 1Panel / PicHost 最终收到的 Host?
```
---
## 相关
- [双域名分离](./domain-separation.md) — 中间件行为与本地测试
- [反向代理](./reverse-proxy.md) — 单域名与源站加固
- [环境变量](./configuration.md) — `SITE_BASE_URL`、`IMAGE_BASE_URL`
- [常见问题](./faq.md) — Pages / Workers 与第三 Host
+103
View File
@@ -0,0 +1,103 @@
# 环境变量
PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。
## 优先级总览
| 配置项 | 优先级(高 → 低) | 环境变量覆盖后台 |
| ------ | ----------------- | ---------------- |
| API 上传 Token | 环境变量 → SQLite | **是**(设了则后台无法重新生成) |
| WebP 质量 | SQLite → 环境变量 → 默认 80 | 否(未在 DB 存时读 env) |
| 防盗链白名单 | SQLite → 环境变量 | 否 |
| 网站 / 图片域名 | SQLite → 环境变量(设置 API 配置值);直链生成另用 `effective*` 或请求 origin | 否 |
| 简短图片链接 | SQLite → 环境变量 → 默认 false | 否 |
| 按年/月分组存储 | SQLite → 环境变量 → 默认 true | 否 |
| 全局自动删除天数 | SQLite → 环境变量 → 默认 0 | 否 |
## 变量说明
### `API_UPLOAD_TOKEN`
外部脚本上传 Token(Twikoo / 油猴 / 博客)。请求头:`Auth-Token: <token>`;表单上传仍可用 `token` 字段。
- 未配置时可在后台 **API** 页生成
- **环境变量一旦设置,优先级最高**,后台显示「由环境变量覆盖」且无法重新生成
- 别名:`NUXT_API_UPLOAD_TOKEN`
### `ALLOWED_REFERER_HOSTS`
防盗链白名单,逗号分隔域名。自身域名自动放行。可选;不填可在设置页配置。若环境变量有值且 SQLite 未存,则使用环境变量。
### `SITE_BASE_URL` / `IMAGE_BASE_URL`
- **网站域名**:管理后台、API、Twikoo 上传入口
- **图片域名**:复制链接与直链使用的公网地址
双域名分离需两者主机名不同,详见 [双域名分离](./domain-separation.md) 与 [Cloudflare / CF 优选部署](./cloudflare-deployment.md)。填写后须与反代 `server_name` 一致;**勿**通过 `localhost`、IP 或未配置的域名访问生产后台。
在后台 **设置** 中保存时须显式勾选「启用后台与图片域名分离」,并同时填写两个域名;关闭双域名会清除网站域配置(需确认)。设置 API 返回的配置值与「当前生效」直链地址已分离,详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。
### `HIDE_FOLDER_IN_URL`
`true` 时使用简短图片链接(仅域名 + 文件名,如 `img.example.com/a8K3xP.webp`);`false` 时保留日期或自定义目录(如 `img.example.com/2026/08/a8K3xP.webp`)。对外 URL **不会** 暴露内部 `images/` 前缀;旧版含 `images/` 的直链仍兼容。
### `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT`
- `STORAGE_USE_DATE_PATH=true`(默认)或 `STORAGE_LAYOUT=date`:新图保存为 `images/年/月/随机ID.webp`
- `false` 或 `STORAGE_LAYOUT=flat`:保存为 `images/随机ID.webp`
也可在设置页切换。
### `DATA_DIR`
图片与 SQLite 根目录。默认 `./data`,容器内为 `/data`。
### `WEBP_QUALITY`
服务端 WebP 压缩质量 1–100,默认 **80**。
### `AUTO_DELETE_DAYS`
全局自动删除超过 N 天的图片,`0` 表示关闭。启用后仅影响**之后新上传**的图片(与按用户策略一致)。也可在设置页配置。
### `DEV_BYPASS_ACCESS`
仅本地开发:`true` 绕过登录验证。生产环境勿用。
### 存储后端(可选)
管理员通常在 **存储** 页添加后端;也可用环境变量引导(如 `STORAGE_BACKEND=s3` 与 `S3_*`)。详见 [存储](./storage.md)。
## Docker Compose 示例
```yaml
services:
pichost:
image: muxui/pichost:latest
ports:
- "6892:6892"
volumes:
- ./data:/data
environment:
SITE_BASE_URL: https://admin.example.com
IMAGE_BASE_URL: https://pic.example.com
API_UPLOAD_TOKEN: your-secret-token
WEBP_QUALITY: "85"
```
修改环境变量后需 **重启容器** 才能生效(Token、质量等读取在启动时或请求时按实现读取)。
## 与后台设置对照
| 后台设置项 | 对应环境变量 |
| ---------- | ------------ |
| API Token | `API_UPLOAD_TOKEN` |
| 防盗链 | `ALLOWED_REFERER_HOSTS` |
| 网站域名 | `SITE_BASE_URL` |
| 图片域名 | `IMAGE_BASE_URL` |
| 隐藏目录前缀 | `HIDE_FOLDER_IN_URL` |
| 按年/月分组 | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
| WebP 质量 | `WEBP_QUALITY` |
| 自动删除 | `AUTO_DELETE_DAYS` |
完整模板见仓库根目录 [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example)。
+180
View File
@@ -0,0 +1,180 @@
# 双域名分离 · 推荐部署架构
启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 双向隔离:
- **网站域**:后台、API、上传;**禁止**直链出图(图片路径返回 404)
- **图片域**:仅放行图片路径;其余请求返回 404
## 架构说明
**单 Docker 实例 + 两个域名**,均可 **全量反代** 到 `6892`(隔离由 PicHost 中间件负责,无需在 Nginx 层拆分路径)。
| 域名 | 用途 |
| ---- | ---- |
| `admin.example.com` | 管理后台、API、Twikoo 上传 |
| `pic.example.com` | 图片直链(中间件拦截非图片路径) |
初始化时在 `/setup` 或后台 **设置** 中填写:
- **网站域名**:`https://admin.example.com`
- **图片域名**:`https://pic.example.com`
首次设置与系统设置**不会**自动把当前浏览器地址写入网站域;可手动填写或点击「填入检测地址」。关闭双域名需确认,且会清除网站域配置。保存时 API 会收到 `domainSeparation: true/false` 字段。详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。
## Nginx 示例
```nginx
# admin.example.com — 后台 + API(不出图,直链请走图片域)
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# 默认 server:拒绝裸 IP、未配置域名等其它 Host(生产环境强烈建议)
server {
listen 443 ssl default_server;
listen 80 default_server;
server_name _;
return 444;
}
```
> Caddy、NPM(Nginx Proxy Manager)等工具同理:两个域名均反代到 `http://<主机>:6892`,并保留 `Host` 与 `X-Forwarded-Proto` 头。
## 安全与注意事项
### 中间件隔离与第三 Host
PicHost 根据请求 **Host**(反代会转发为 `X-Forwarded-Host`,应用会一并识别)判断当前是网站域还是图片域:
- **图片域**:非图片路径(如 `/`、`/settings`)→ 404
- **网站域**:图片直链路径(如 `/images/...`)→ 404
- **未配置的第三 Host**(如 `*.pages.dev`、裸 IP、旧图片域):**v1.2.x 起应用层一律 404**(开发环境 `localhost` / `127.0.0.1` 例外,便于 `npm run dev`)
这与域名是否带 `-`、长短无关;此前版本对第三 Host 不拦截,需依赖反代 default server 封堵。
**生产环境建议:**
1. 反代层仅为网站域、图片域配置 `server_name`,并增加 **default server** 拒绝其它 Host 与裸 IP(见上方 Nginx 示例,与应用层拦截互补)
2. 使用 Cloudflare **橙云** 时,源站防火墙 **仅放行 [Cloudflare IP 段](https://www.cloudflare.com/ips/)**,避免绕过 CDN 直连源站
3. **仅使用** 设置中的网站域名登录后台,不要用 `localhost`、IP 或未配置的域名混用
4. **不要** 用 Cloudflare Pages / Workers 对 PicHost **整站反代**(会多出 `pages.dev` / `workers.dev` 等第三入口;`IMAGE_BASE_URL` 可填 CDN 公网地址,但应用本体应 Docker/VPS 部署)
5. 若必须在边缘做反代,须保留客户端 **`Host` 或 `X-Forwarded-Host`**,否则 Host 隔离会失效
6. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可
### Cloudflare 橙云(含 DNS 优选)
完整说明见专章:[Cloudflare / CF 优选部署](./cloudflare-deployment.md)。
**推荐:** `admin.example.com` 与 `pic.example.com` 均 **DNS Proxied(橙云)** → 源站 Nginx / 1Panel 反代 `6892`,SSL 模式 **Full (strict)**。
| 能力 | 说明 |
| ---- | ---- |
| 橙云 CDN | 与 PicHost 双域名兼容;回源须保留 `Host`、`X-Forwarded-Proto` |
| DNS 优选 | 仅改变用户连到 CF 边缘的 IP;**admin → admin、img → img** 的回源身份不能变 |
| R2 对象存储 | 在 **存储** 页添加 R2 后端即可,与是否橙云无关 |
| `cloudflare` Git 分支 | R2 专用部署线,仍是 Docker 运行,**不是** Workers 托管 |
**勿混淆:** CF 控制台「创建 Worker」并连接本仓库 **无法** 直接部署 PicHost;`npm run build` + `wrangler deploy` 与当前 `node-server` 预设不兼容。
**勿用 Pages/Worker 把图片域 `fetch` 到管理域** — 会破坏 Host 隔离(详见 [Cloudflare 部署说明 · 典型错误拓扑](./cloudflare-deployment.md#六典型错误拓扑))。
反代与源站加固详见 [反向代理](./reverse-proxy.md#cloudflare-橙云)。
## 飞牛 NAS · Lucky 反向代理
在 [Lucky](https://github.com/gdy666/lucky) 中为两个域名各建一条 **反向代理** 规则,**前端地址** 填公网域名,**后端地址** 填 PicHost 所在内网 IP 与端口(如 `http://192.168.8.3:6892`)。
**网站域名(管理后台)**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `admin.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
**图片域名**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `image.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
两条规则的后端地址相同即可;域名分离与路径隔离由 PicHost 根据请求 `Host` 自动处理。
## 环境变量(可选)
除 Web 设置外,也可在 `.env` 或 Docker 环境中配置:
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
详见 [环境变量](./configuration.md)。
## 本地测试(Windows / macOS)
### 1. 修改 hosts
```text
127.0.0.1 admin.pichost.test
127.0.0.1 pic.pichost.test
```
建议使用 `.test` 后缀,避免 `.local` 在部分系统上与 mDNS 冲突。
### 2. 启动开发服务
```bash
npm run dev
```
项目已默认绑定 `127.0.0.1:3000`(与 hosts 的 IPv4 一致)。若曾出现 `ping` 通但浏览器连不上,多半是 dev 只监听了 IPv6 `::1`。
### 3. 填写双域名
| 项 | 示例 |
| -- | ---- |
| 网站域名 | `http://admin.pichost.test:3000` |
| 图片域名 | `http://pic.pichost.test:3000` |
**后台请始终用网站域名打开**,不要用 `localhost`(Cookie 与 Host 不一致)。
### 4. 预期结果
| 地址 | 预期 |
| ---- | ---- |
| `http://admin.pichost.test:3000/` | 正常进入后台 |
| `http://admin.pichost.test:3000/images/...` 或 `/2026/08/xxx.webp` | **404**(请用图片域直链) |
| `http://pic.pichost.test:3000/` | **404**(正常,图片域不提供后台) |
| `http://pic.pichost.test:3000/images/...` | 能出图 |
| `http://localhost:3000/` 或 `http://127.0.0.1:3000/` | **能打开后台**(开发环境例外;生产须用反代封禁未配置 Host) |
上传一张图后,复制链接应指向 `pic.pichost.test` 域名;在后台图库中缩略图也应能正常显示。
## 相关说明
- 两个域名 **主机名不能相同**(可同机、不同子域)
- 设置里的网站域 / 图片域须与反代 `server_name` **完全一致**(含是否带 `www`)
- 图片域无需单独部署静态目录;与 EasyImages 等不同,PicHost 在应用内完成隔离
- 单域名部署可不启用分离,仅配置 `IMAGE_BASE_URL` 或使用默认当前访问域名
- 配置了 Referer 防盗链时,网站域与图片域会自动加入白名单,无需手写两个域名
单域名反代要点见 [反向代理](./reverse-proxy.md)。
+78
View File
@@ -0,0 +1,78 @@
# 常见问题
## 忘记管理员密码怎么办?
能执行 `docker exec` 时:
```bash
docker exec pichost reset-password
# 或指定用户名
docker exec pichost reset-password 用户名
```
本地:`npm run reset-password`。详见 [快速开始](./getting-started.md#忘记密码)。
## 为什么后台无法重新生成 API Token?
若设置了环境变量 `API_UPLOAD_TOKEN`,Token 由环境变量 **锁定**,需修改 env 并 **重启服务**。见 [环境变量](./configuration.md#api-upload-token)。
## 图片外链显示「盗链」或无法加载?
检查 **防盗链白名单**(`ALLOWED_REFERER_HOSTS` 或设置页)。引用图片的站点域名需在白名单中;PicHost 自身域名与双域名场景下的网站域、图片域会自动放行。
## 双域名下为什么用 pages.dev / workers.dev / IP 也能进后台?
**v1.2.2 起:** 双域名开启时,PicHost 中间件会对**未配置的网站域、图片域之外**的 Host 返回 **404**(开发环境 `localhost` / `127.0.0.1` 例外)。详见 [更新日志](./changelog.md#1-2-2-2026-08-30)。
若仍能访问,常见原因:
1. **未启用双域名**(仅配置了图片域、网站域为空)— 隔离与第三 Host 拦截均不生效
2. **反代把 Host 改成管理域** — 例如 Pages/Worker 对图片域 `fetch(admin…)`,PicHost 看到的是管理域
3. **源站裸暴露 `6892`** — 绕过 Cloudflare 与 Nginx `server_name`
处理:在设置中正确配置双域名;反代加 default server;橙云时源站只放行 CF IP;勿用 Pages/Workers 整站反代。详见 [双域名分离](./domain-separation.md#中间件隔离与第三-host)、[Cloudflare 部署](./cloudflare-deployment.md) 与 [更新日志](./changelog.md#1-2-2-2026-08-30)。
## 能把 PicHost 部署到 Cloudflare Workers / Pages 吗?
**不能(现状)。** 项目使用 `node-server`、SQLite、`sharp` 与本地 `data/`,需 Docker / VPS。CF 控制台「从 Git 创建 Worker」连本仓库会部署失败。
推荐:源站跑 PicHost,CF 用 **橙云 DNS**(含优选)+ 可选 **R2 存储**;不要用 Pages/Workers 反代整站。
## 双域名下后台能打开但缩略图裂图?
- 确认 **图片域名** 配置正确且反代到同一实例
- 后台请用 **网站域名** 打开,不要用 `localhost` 混用
- 见 [双域名分离](./domain-separation.md)
## 升级 v1.2 后图库有删不掉的记录?
可能是 **孤儿索引**(有记录无文件)。重启后启动同步会自动清理;也可手动删除仅索引项。见 [v1.2 迁移](./migration.md#第二步启动时自动同步索引)。
## 遗留 `data/blog/` 目录还要吗?
执行 `migrate --apply` 后文件已迁入 `data/images/blog/`。若原目录只剩空壳或非图片文件,可自行备份后删除;迁移脚本不会删除仍含非图片文件的目录。
## 只用对象存储需要跑 migrate 吗?
不需要。图片在桶内时跳过 CLI;启动同步主要面向本地 `data/images/` 磁盘扫描。
## 如何替换博客里的旧图片 URL?
使用 `data/mapping.json`(`migrate` 预览时生成)批量替换旧 key。注意映射的是 **存储 key**,不是完整旧站 URL。见 [迁移指南](./migration.md#外链与直链)。
## 开发时如何跳过登录?
`DEV_BYPASS_ACCESS=true`(仅本地,勿用于生产)。正常流程请访问 `/setup` 创建管理员。
## 文档与仓库 README 哪个为准?
**本 VitePress 文档站** 为完整用户指南;README 保留摘要与快速开始。在线地址:<https://o96u.github.io/PicHost/>
## GitHub 仓库 About 怎么写?
**main** 分支建议:
- **Description**:`Lightweight self-hosted image hosting — Docker, multi-user, gallery & API, local disk or S3-compatible storage.`
- **Website**:`https://o96u.github.io/PicHost/`(文档站,非图床演示)
勿写「部署在 Cloudflare Pages/Workers」—— PicHost 应用跑在 Docker/VPS;**cloudflare** 分支在线演示为 [pic.roven.cc](https://pic.roven.cc)。
+75
View File
@@ -0,0 +1,75 @@
# 快速开始
PicHost 适合在 NAS、家用服务器或 VPS 上自托管图床。推荐用 Docker 部署,首次访问通过 Web 向导创建管理员。
## 在线体验
| 分支 | 说明 |
| ---- | ---- |
| **main** | 无公网演示;产品界面见 [文档首页](./index.md#截图预览) 或仓库 README |
| **cloudflare** | R2 专用线在线演示:[pic.roven.cc](https://pic.roven.cc) |
## Docker(推荐)
```bash
docker run -d \
--name pichost \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
```
默认端口 **6892**。浏览器打开 `http://<主机IP>:6892`,按引导创建管理员即可。
已克隆仓库时也可用:
```bash
docker compose up -d
```
见仓库内 `docker-compose.yml`。
## 首次设置
1. 访问实例 URL(Docker 默认 `http://<主机>:6892`)。
2. 在 `/setup` 填写管理员用户名与密码。
3. (可选)配置网站域名、图片域名、防盗链等 — 详见 [环境变量](./configuration.md) 与 [双域名分离](./domain-separation.md)。
4. 登录后可在首页上传图片;管理员可进入 **存储**、**设置**、**API** 页。
## 登录
访问上传、统计、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。
![登录页](/screenshots/login.png)
若管理员开启了开放注册,可前往注册页创建账号。遗留部署仍可能使用密钥登录(见界面提示)。
## 忘记密码
需能执行 `docker exec`(相当于服务器权限):
```bash
# 无参数:重置管理员(仅当系统中只有一个管理员时)
docker exec pichost reset-password
# 指定用户名:可重置管理员或普通用户
docker exec pichost reset-password 用户名
```
终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。
本地开发:`npm run reset-password` 或 `npm run reset-password -- 用户名`
## 升级到 v1.2.0
若 `data/` 下存在与 `images` 并列的遗留目录(如 `blog/`、`twikoo/`),升级前请阅读 [v1.2 迁移指南](./migration.md)。
仅对象存储、或图片已在 `data/images/` 下时,拉取新镜像并重启即可。
## 下一步
- [本地开发](./local-dev.md) — `npm run dev` 与 `.env`
- [环境变量](./configuration.md) — 生产配置
- [存储](./storage.md) — 多后端与直链
- [API](./api.md) — Token 与 REST 接口
+40
View File
@@ -0,0 +1,40 @@
# PicHost 文档
欢迎使用 PicHost 用户文档。本站为完整指南的单一事实来源;仓库 README 保留特性摘要与快速开始。
## 推荐阅读顺序
1. [快速开始](./getting-started.md) — Docker 部署与首次设置
2. [环境变量](./configuration.md) — 生产环境配置与后台设置对照
3. [更新日志](./changelog.md) — 版本变更与升级说明
4. [存储](./storage.md) — 多后端与直链模式
5. [API](./api.md) — REST 接口与 Token
## 截图预览
**main** 分支暂无公网演示,以下为 v1.2.0 产品界面。
| 首次设置 | 登录 |
| :------: | :--: |
| ![首次设置](/screenshots/setup.png) | ![登录](/screenshots/login.png) |
| API | 统计与图库 |
| :-: | :--------: |
| ![API](/screenshots/api.png) | ![统计与图库](/screenshots/stats.png) |
| 存储管理 | 系统设置 |
| :------: | :------: |
| ![存储管理](/screenshots/storage.png) | ![系统设置](/screenshots/settings.png) |
| 操作日志 |
| :------: |
| ![操作日志](/screenshots/logs.png) |
## 分支说明
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main) | 主线:多后端、双域名、统一 `images/` 存储;界面见上表 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | 仅 Cloudflare R2;在线演示 [pic.roven.cc](https://pic.roven.cc) |
需要多种云存储或完整管理界面请用 **main**;图床只接 R2 可选用 **cloudflare** 分支。
+54
View File
@@ -0,0 +1,54 @@
# 本地开发
在仓库根目录进行 Nuxt 4 全栈开发,数据默认写入 `./data`。
## 环境要求
- Node.js **22**
- npm **11**(与 `package.json` 中 `packageManager` 一致)
## 启动
```bash
npm install
cp .env.example .env # 可选
npm run dev
```
访问 `http://localhost:3000/setup` 创建管理员。数据目录默认 `./data`(`DATA_DIR` 可改)。
## 常用命令
| 命令 | 说明 |
| ---- | ---- |
| `npm run dev` | 开发服务器 |
| `npm run build` | 生产构建 |
| `npm run start` | 运行 `.output` |
| `npm run lint` | ESLint |
| `npm run typecheck` | 类型检查 |
| `npm test` | 单元测试 |
| `npm run reset-password` | 重置密码(可选用户名) |
| `npm run migrate` | 预览遗留目录迁移(加 `-- --apply` 执行) |
## 开发专用环境变量
| 变量 | 说明 |
| ---- | ---- |
| `DEV_BYPASS_ACCESS` | 设为 `true` 可绕过登录(跳过 `/setup`,一般无需开启) |
| `DATA_DIR` | 数据目录,默认 `./data` |
完整变量列表见 [环境变量](./configuration.md)。
## 双域名本地测试
修改系统 `hosts` 后可用两个测试域名验证分离逻辑,步骤见 [双域名分离](./domain-separation.md#本地测试-windows-macos)。
## 文档站
```bash
npm run docs:dev # VitePress 开发
npm run docs:build # 构建静态站点
npm run docs:preview # 预览构建结果
```
文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png` 登录页),构建前会复制到 `docs-site/public/screenshots/`。在线文档:<https://o96u.github.io/PicHost/>
+176
View File
@@ -0,0 +1,176 @@
# 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` | 重置密码(与迁移无关) |
+85
View File
@@ -0,0 +1,85 @@
# 反向代理
生产环境建议通过 Nginx、Caddy、NPM(Nginx Proxy Manager)或 NAS 自带反代,将 HTTPS 流量转发到 PicHost 容器 **6892** 端口。
## 基本要求
- 反代目标:`http://127.0.0.1:6892`(或容器所在主机 IP)
- 保留请求头:`Host`、`X-Forwarded-Proto`(及常见的 `X-Forwarded-For`)
- 上传大小:Nginx 建议 `client_max_body_size 12m` 或更大
- 公网访问请配置 **HTTPS**
## 单域名示例(Nginx)
管理、API、出图同一域名:
```nginx
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
```
环境变量(可选):
```env
IMAGE_BASE_URL=https://pic.example.com
```
未设置 `IMAGE_BASE_URL` 时,PicHost 会根据反代转发的 `Host` 与 `X-Forwarded-Proto` 推断公网地址。
## 双域名
若后台与图片使用不同域名,两个 `server` 块均可 **全量反代** 到同一端口;隔离由 PicHost 中间件完成。
详见专章:[双域名分离](./domain-separation.md)。
## Cloudflare 橙云
详见专章:[Cloudflare / CF 优选部署](./cloudflare-deployment.md)。
| 做法 | 说明 |
| ---- | ---- |
| **DNS 橙云** | `admin` / `img` 两个子域 Proxied → 源站反代 `6892`,SSL **Full (strict)** |
| **DNS 优选** | 与 PicHost 兼容;须保持 **admin → admin、img → img** 回源 |
| **R2** | 在存储页作对象后端,非 Pages 托管应用 |
| **源站防火墙** | 仅放行 [Cloudflare IP](https://www.cloudflare.com/ips/),禁止公网直连 `6892` |
**不建议:**
- 用 **Cloudflare Pages**(`*.pages.dev`)或 **Workers**(`*.workers.dev`)对 PicHost **整站反代**,或让图片域 **fetch 到管理域** — 会改写 Host、绕过双域名隔离(见 [Cloudflare 部署说明](./cloudflare-deployment.md))
- 在 CF 控制台「从 Git 创建 Worker」部署本仓库 — 当前为 `node-server` 预设,无法直接 `wrangler deploy`
## 源站安全(双域名推荐)
除 PicHost 中间件外,建议在反代 / 防火墙层加固:
1. **`server_name` 白名单** — 仅网站域与图片域;`default_server` 拒绝裸 IP 与其它 Host
2. **禁止公网暴露 6892** — 仅本机或内网反代访问;公网只开 80/443
3. **橙云时限制源站来源 IP** — 仅 Cloudflare 回源 IP 段
双域名场景下 PicHost **不会** 拒绝未配置的 Host;上述措施由运维层补齐。
## Caddy 示例
```caddy
pic.example.com {
reverse_proxy 127.0.0.1:6892
}
```
双域名时添加第二个站点块,后端地址相同即可。
## 缓存注意
图片 404/403 与域名隔离 404 响应带 **禁止缓存** 头,避免 CDN 长时间缓存错误页面。若在前端再加 CDN,请对动态 API 路径谨慎缓存。
## 相关
- [环境变量](./configuration.md) — `SITE_BASE_URL`、`IMAGE_BASE_URL`
- [快速开始](./getting-started.md) — Docker 端口
+72
View File
@@ -0,0 +1,72 @@
# 存储
PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。
![存储管理](/screenshots/storage.png)
## 存储结构(本地)
```
/data
├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
└── images/ # 本地后端时的全部图片文件
```
- 新上传:`images/随机ID.webp` 或 `images/年/月/随机ID.webp`(可在设置中切换扁平模式)
- 迁移遗留:`images/blog/...` 等子路径
- 云存储时文件在对应桶内,索引仍在 SQLite `images` 表
所有图片 **key** 以 `images/` 开头。上传 API **不再接受** `folder` 参数。
## 添加存储后端
1. 以管理员登录,打开 **存储** → **添加存储**。
2. 选择类型:**本地磁盘** 或 **S3 兼容**(含 R2 / COS / OSS 预设)。
3. 填写连接信息(桶名、Endpoint、Access Key 等)。
4. 保存后可 **设默认**,新上传将写入该后端。
### Cloudflare R2 要点
| 项 | 值 |
| -- | -- |
| Endpoint | `https://<account_id>.r2.cloudflarestorage.com` |
| Region | `auto` |
| 桶 | 你的 R2 桶名 |
| 密钥 | R2 API Token 的 Access Key / Secret Key |
若仓库 **cloudflare** 分支专用于「仅 R2」部署,也可用环境变量预设,见 [文档首页](./index.md) 分支说明。
### 环境变量(可选)
部分部署用环境变量初始化默认 S3 后端(如 `STORAGE_BACKEND=s3`、`S3_ENDPOINT`、`S3_BUCKET`、`S3_ACCESS_KEY`、`S3_SECRET_KEY`、`S3_REGION`)。**UI 中配置的后端信息存于 SQLite**,与环境变量并存时以管理界面为准管理多后端。
## 直链模式
每个后端可配置公开 URL 行为:
| 模式 | 说明 |
| ---- | ---- |
| **proxy** | 通过 PicHost 同源代理出图(`GET /images/...`) |
| **public** | 302 重定向到桶/CDN 公网地址 |
复制链接时使用 **图片域名**(`IMAGE_BASE_URL`)拼接路径。开启「隐藏 images 前缀」后 URL 可能更短,服务按文件名或路径反查索引。
## 用量与筛选
- 存储页展示各后端用量(本地扫描目录,对象存储查询 API)
- 图库可按存储后端筛选(管理员)
## 备份
请同时备份:
- `data/images/`(本地后端)
- `data/pichost.db`
- 各云桶中的对象
从 v1.1.x 升级且存在 `data/blog/` 等并列目录时,见 [v1.2 迁移](./migration.md)。
## 相关
- [环境变量](./configuration.md) — `STORAGE_USE_DATE_PATH`、`HIDE_FOLDER_IN_URL`
- [用户与权限](./users-and-permissions.md) — 谁可以管理存储
+53
View File
@@ -0,0 +1,53 @@
# Twikoo
PicHost 兼容 **Twikoo** 与 **EasyImage 2.0** 图片上传协议,评论系统可像接 EasyImage 一样配置。
## Twikoo 配置
在 Twikoo 环境变量或配置中设置:
| 配置项 | 值 |
| ------ | -- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://你的域名/api/index.php` |
| `IMAGE_CDN_TOKEN` | 与全局 `API_UPLOAD_TOKEN` 相同 |
- 使用 **网站域名**(管理后台域)作为 `IMAGE_CDN_URL` 的主机名
- Token 需在 PicHost **API** 页生成,或通过 `API_UPLOAD_TOKEN` 环境变量设置
## 上传接口
`POST /api/index.php`
表单字段:
| 字段 | 说明 |
| ---- | ---- |
| `image` | 图片文件 |
| `token` | 全局 API Token(与 `Auth-Token` 等效) |
```bash
curl -X POST "https://admin.example.com/api/index.php" \
-F "token=YOUR_TOKEN" \
-F "image=@./demo.png"
```
上传的图片存入 `images/` 目录,归属与 **全局 Token** 相同(管理员)。
## 与 REST API 的区别
| 项目 | REST `/api/images/upload` | Twikoo `/api/index.php` |
| ---- | ------------------------- | ------------------------ |
| 鉴权 | `Auth-Token` 请求头 | 表单 `token` |
| 响应格式 | PicHost JSON | EasyImage 兼容 JSON |
| Token | 全局或个人 | 仅全局 Token |
## 双域名部署
Twikoo 上传应走 **网站域名**(`SITE_BASE_URL`),图片展示走 **图片域名**(`IMAGE_BASE_URL`)。详见 [双域名分离](./domain-separation.md)。
## 相关
- [API](./api.md)
- [用户与权限](./users-and-permissions.md)
- [环境变量](./configuration.md)
+58
View File
@@ -0,0 +1,58 @@
# 用户与权限
PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)** 与 **普通用户(user)**。权限在服务端强制,不可仅靠前端绕过。
## 角色能力
| 能力 | 管理员 | 普通用户 |
| ---- | :----: | :------: |
| 上传图片 | ✓ | ✓ |
| 查看自己的图库 | ✓ | ✓ |
| 查看全部图库 / 上传者标签 | ✓ | — |
| 存储后端管理(`/storage`) | ✓ | — |
| 系统设置(`/settings`) | ✓ | — |
| 开放注册开关 | ✓ | — |
| 全局 API Token 管理 | ✓ | — |
| 个人 API Token | ✓ | ✓ |
| 统计页(含注册用户数) | ✓ | 部分 |
| 操作日志 | ✓ | — |
普通用户顶栏:**API**、**统计**;用户菜单:**修改密码**、**退出登录**。
## 鉴权场景
| 场景 | 鉴权方式 | 图片归属 / 可见范围 |
| ---- | -------- | ------------------- |
| 网页上传 | Session Cookie | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` 头 | Token 所属用户 |
| API 上传 + **全局 Token** | `Auth-Token` 头 | 管理员(`userId` 为空) |
| Twikoo `POST /api/index.php` | 表单 `token` | 与全局 Token 相同 |
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
## 注册与账号
- 默认仅管理员可创建用户;管理员可在设置中 **开放注册**
- 密码使用 scrypt 哈希存储
- Web 登录需先完成滑动拼图验证(拖动滑块对齐缺口)
- 登录失败有速率限制
## 上传偏好与自动删除
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接等,按用户保存
- **自动删除**:管理员可设全局策略;用户可设个人策略。启用后仅影响 **之后新上传** 的图片
## API Token 说明
| 类型 | 谁管理 | 上传归属 | 典型用途 |
| ---- | ------ | -------- | -------- |
| **全局 Token** | 管理员(API 页) | 管理员账号 | Twikoo、站点级脚本 |
| **个人 Token** | 各用户(API 页) | 该用户 | 个人博客、私有脚本 |
若设置了环境变量 `API_UPLOAD_TOKEN`,则全局 Token 被锁定,见 [环境变量](./configuration.md)。
## 相关
- [API](./api.md)
- [Twikoo](./twikoo.md)
- [存储](./storage.md)
+32
View File
@@ -0,0 +1,32 @@
---
layout: home
hero:
name: PicHost
text: 个人轻量图床
tagline: 自托管 · 多用户 · Docker · API · Twikoo · 本地磁盘或对象存储
actions:
- theme: brand
text: 快速开始
link: /guide/getting-started
- theme: alt
text: 查看 API
link: /guide/api
- theme: alt
text: GitHub
link: https://github.com/O96u/PicHost
features:
- title: 拖拽 / 粘贴上传
details: 支持点击、拖拽与 Ctrl+V 粘贴;服务端 WebP 压缩与 Referer 防盗链。
- title: 多后端存储
details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);proxy 或 public 直链模式。
- title: 多用户与权限
details: 管理员与普通用户 RBAC;全局 Token 与个人 Token;图库按归属隔离。
- title: Docker 零配置
details: 首次访问 Web 引导创建管理员;`docker exec pichost reset-password` 重置密码。
- title: 双域名分离
details: 单实例 + 两个域名全量反代;应用内中间件隔离后台与图片域。
- title: Twikoo 兼容
details: "POST /api/index.php 兼容 EasyImage 2.0 协议,评论系统一键接入。"
---
-62
View File
@@ -1,62 +0,0 @@
# PicHost 文档资源
本目录主要存放 README 引用的界面截图与分支说明。
## 分支
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.0** 主线:本地磁盘 + 多对象存储(R2 / COS / OSS / AWS)、`/storage` 管理页、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署场景,配置更聚焦 R2 |
- 需要多种云存储或完整管理界面 → 使用 **main**
- 图床只接 **Cloudflare R2** → 可选用 **`cloudflare`** 分支
## 截图一览
以下为 **main(v1.1.0)** 界面截图。
| 文件 | 说明 |
| ---- | ---- |
| `screenshots/upload.png` | 首页上传区(顶栏含 API / 统计 / 存储 / 设置) |
| `screenshots/storage.png` | 存储管理(`/storage`):多后端卡片、用量、设默认 |
| `screenshots/stats.png` | 统计与图库 |
| `screenshots/settings.png` | 系统设置(上传偏好 + 系统设置) |
| `screenshots/preferences.png` | 首页上传卡片翻转后的上传偏好 |
| `screenshots/login.png` | 登录页 |
| `screenshots/lucky-site.png` | 飞牛 NAS Lucky:网站域名反代示例 |
| `screenshots/lucky-image.png` | 飞牛 NAS Lucky:图片域名反代示例 |
## 部署文档
| 文档 | 说明 |
| ---- | ---- |
| [domain-separation.md](domain-separation.md) | 双域名分离推荐架构(Nginx / Lucky / 环境变量) |
| [domain-separation.en.md](domain-separation.en.md) | Dual-domain deployment (English) |
## 相关页面路由(main / v1.1.0)
| 路由 | 权限 | 说明 |
| ---- | ---- | ---- |
| `/` | 登录用户 | 上传与图库入口 |
| `/storage` | 管理员 | 存储后端管理 |
| `/settings` | 管理员 | 系统设置 |
| `/stats` | 登录用户 | 统计 |
| `/api` | 登录用户 | API 文档与 Token |
管理员顶栏:**API** · **统计** · **存储** · **设置**。用户下拉菜单仅 **修改密码**、**退出登录**。
## Cloudflare R2 配置对照
**main**:后台 **存储 → 添加存储 → Cloudflare R2** 预设。
**cloudflare 分支**(R2 专用):通常以环境变量为主:
| 配置项 | 值 |
| ------ | -- |
| `STORAGE_BACKEND` | `s3` |
| `S3_ENDPOINT` | `https://<account_id>.r2.cloudflarestorage.com` |
| `S3_REGION` | `auto` |
| `S3_BUCKET` | 桶名 |
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | R2 API Token |
| 直链 | 默认 PicHost 代理;桶绑域名后可改公开 URL |
-83
View File
@@ -1,83 +0,0 @@
# Dual-domain separation · Recommended deployment
When admin and image domains are split, the admin UI, API, and public image links use different hostnames. PicHost enforces isolation in **application middleware**: the image host only serves image paths; other requests return 404.
## Architecture
**One Docker instance + two domains**, both **fully reverse-proxied** to port `6892` (isolation is handled by PicHost middleware; no path splitting in Nginx).
| Domain | Purpose |
| ------ | ------- |
| `admin.example.com` | Admin UI, API, Twikoo upload |
| `pic.example.com` | Public image links (non-image paths blocked) |
Set during `/setup` or in **Settings**:
- **Site URL**: `https://admin.example.com`
- **Image URL**: `https://pic.example.com`
## Nginx example
```nginx
# admin.example.com — admin + API + images
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — same full proxy; PicHost middleware blocks non-image paths
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
> Caddy, NPM, and similar tools work the same: proxy both hostnames to `http://<host>:6892` and forward `Host` and `X-Forwarded-Proto`.
## FN NAS · Lucky reverse proxy
In [Lucky](https://github.com/gdy666/lucky), create one **reverse proxy** rule per domain. **Frontend** is the public hostname; **backend** is PicHost on your LAN (e.g. `http://192.168.8.3:6892`).
**Site domain (admin)**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `admin.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
![Lucky site domain](screenshots/lucky-site.png)
**Image domain**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `image.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
![Lucky image domain](screenshots/lucky-image.png)
Both rules can point to the same backend; PicHost applies host-based isolation automatically.
## Environment variables (optional)
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
## Notes
- Hostnames must **differ** (same machine, different subdomains is fine)
- No separate static root on the image domain; unlike EasyImages, PicHost isolates in-app
- Single-domain setups can skip separation and use `IMAGE_BASE_URL` or the request host
-125
View File
@@ -1,125 +0,0 @@
# 双域名分离 · 推荐部署架构
启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 在应用层隔离:图片域仅放行图片路径,其余请求返回 404。
## 架构说明
**单 Docker 实例 + 两个域名**,均可 **全量反代** 到 `6892`(隔离由 PicHost 中间件负责,无需在 Nginx 层拆分路径)。
| 域名 | 用途 |
| ---- | ---- |
| `admin.example.com` | 管理后台、API、Twikoo 上传 |
| `pic.example.com` | 图片直链(中间件拦截非图片路径) |
初始化时在 `/setup` 或后台 **设置** 中填写:
- **网站域名**:`https://admin.example.com`
- **图片域名**:`https://pic.example.com`
## Nginx 示例
```nginx
# admin.example.com — 后台 + API + 出图
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
> Caddy、NPM(Nginx Proxy Manager)等工具同理:两个域名均反代到 `http://<主机>:6892`,并保留 `Host` 与 `X-Forwarded-Proto` 头。
## 飞牛 NAS · Lucky 反向代理
在 [Lucky](https://github.com/gdy666/lucky) 中为两个域名各建一条 **反向代理** 规则,**前端地址** 填公网域名,**后端地址** 填 PicHost 所在内网 IP 与端口(如 `http://192.168.8.3:6892`)。
**网站域名(管理后台)**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `admin.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
![Lucky 网站域名配置](screenshots/lucky-site.png)
**图片域名**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `image.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
![Lucky 图片域名配置](screenshots/lucky-image.png)
两条规则的后端地址相同即可;域名分离与路径隔离由 PicHost 根据请求 `Host` 自动处理。
## 环境变量(可选)
除 Web 设置外,也可在 `.env` 或 Docker 环境中配置:
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
## 本地测试(Windows / macOS)
### 1. 修改 hosts
```text
127.0.0.1 admin.pichost.test
127.0.0.1 pic.pichost.test
```
建议使用 `.test` 后缀,避免 `.local` 在部分系统上与 mDNS 冲突。
### 2. 启动开发服务
```bash
npm run dev
```
项目已默认绑定 `127.0.0.1:3000`(与 hosts 的 IPv4 一致)。若曾出现 `ping` 通但浏览器连不上,多半是 dev 只监听了 IPv6 `::1`。
### 3. 填写双域名
| 项 | 示例 |
| -- | ---- |
| 网站域名 | `http://admin.pichost.test:3000` |
| 图片域名 | `http://pic.pichost.test:3000` |
**后台请始终用网站域名打开**,不要用 `localhost`(Cookie 与 Host 不一致)。
### 4. 预期结果
| 地址 | 预期 |
| ---- | ---- |
| `http://admin.pichost.test:3000/` | 正常进入后台 |
| `http://pic.pichost.test:3000/` | **404**(正常,图片域不提供后台) |
| `http://pic.pichost.test:3000/images/...` | 能出图 |
| `http://localhost:3000/` | 能打开,但与双域名配置无关,登录态不共享 |
上传一张图后,复制链接应指向 `pic.pichost.test` 域名;在后台图库中缩略图也应能正常显示。
## 相关说明
- 两个域名 **主机名不能相同**(可同机、不同子域)
- 图片域无需单独部署静态目录;与 EasyImages 等不同,PicHost 在应用内完成隔离
- 单域名部署可不启用分离,仅配置 `IMAGE_BASE_URL` 或使用默认当前访问域名
- 配置了 Referer 防盗链时,网站域与图片域会自动加入白名单,无需手写两个域名
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+3
View File
@@ -2,5 +2,8 @@
import withNuxt from './.nuxt/eslint.config.mjs' import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt( export default withNuxt(
{
ignores: ['docs-site/**']
}
// Your custom configs here // Your custom configs here
) )
+30 -29
View File
@@ -64,6 +64,8 @@
"loginFailedSecret": "Sign-in failed. Check your secret", "loginFailedSecret": "Sign-in failed. Check your secret",
"loginFailedCredentials": "Invalid username or password", "loginFailedCredentials": "Invalid username or password",
"loginFailed": "Sign-in failed. Check username and password", "loginFailed": "Sign-in failed. Check username and password",
"captchaHint": "Drag the slider to the slot",
"captchaSuccess": "Verified",
"registerFailed": "Registration failed. Check your input", "registerFailed": "Registration failed. Check your input",
"setupFailed": "Setup failed. Check your input", "setupFailed": "Setup failed. Check your input",
"migrateFailed": "Migration failed. Check your input", "migrateFailed": "Migration failed. Check your input",
@@ -88,7 +90,7 @@
"domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)", "domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
"domainSeparationProxyExample": "View example", "domainSeparationProxyExample": "View example",
"siteBaseUrl": "Site URL", "siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint", "siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com", "siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "Image URL", "imageBaseUrl": "Image URL",
"imageBaseUrlHint": "No trailing slash; public image link prefix", "imageBaseUrlHint": "No trailing slash; public image link prefix",
@@ -179,18 +181,16 @@
"settings": { "settings": {
"title": "System settings", "title": "System settings",
"subtitle": "Saved to database; environment variables take precedence", "subtitle": "Saved to database; environment variables take precedence",
"uploadAccount": "Upload & accounts", "featureToggles": "Feature toggles",
"defaultFolder": "Default upload folder",
"defaultFolderHint": "Stored in this browser only; affects your upload target",
"allowRegistration": "Open registration", "allowRegistration": "Open registration",
"allowRegistrationHint": "Allow new users to register (regular users see only their images)", "allowRegistrationHint": "Allow new users to register (regular users see only their images)",
"accessControl": "Access control", "accessControl": "Access control",
"refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction", "refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction",
"refererPlaceholder": "example.com, www.example.com", "refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "Domains & links", "domainSettings": "Domain settings",
"domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API", "domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
"siteBaseUrl": "Site URL", "siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo upload endpoint", "siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com", "siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "Active: {url}", "siteBaseUrlActive": "Active: {url}",
"imageBaseUrl": "Image URL", "imageBaseUrl": "Image URL",
@@ -198,11 +198,17 @@
"imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL", "imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL",
"imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "Active: {url}", "imageBaseUrlActive": "Active: {url}",
"hideFolderInUrl": "Hide folder in image URLs", "runtimeDetected": "Detected current access: {url} (will not be saved automatically)",
"hideFolderInUrlHint": "Links become /2026/08/file.webp without the images/ prefix; legacy full paths still work", "fillDetectedOrigin": "Use detected URL",
"hideFolderConfirmTitle": "Hide folder prefix in URLs?", "hostRoleUnknown": "Current Host does not match configured domains; reverse-proxy bypass may be possible",
"hideFolderConfirmDesc": "If two folders share the same year/month/filename (e.g. images/2026/08/a.webp and blog/2026/08/a.webp), the short URL /2026/08/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.", "disableDomainSeparationTitle": "Disable domain separation",
"hideFolderConfirmEnable": "Enable anyway", "disableDomainSeparationDesc": "This clears the site URL and keeps only the image URL. Continue?",
"disableDomainSeparationConfirm": "Disable",
"domainSeparationRequired": "Both site and image URLs are required when domain separation is enabled",
"hideFolderInUrl": "Use short image links",
"hideFolderInUrlHint": "When on, links are only domain + filename, e.g. img.example.com/abc.webp. When off, date or custom folders are kept, e.g. img.example.com/2026/08/abc.webp",
"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", "badgeEnv": ".env",
"badgeSaved": "Saved", "badgeSaved": "Saved",
"badgeDefault": "Default", "badgeDefault": "Default",
@@ -312,12 +318,11 @@
"deleteTotal": "Total deleted", "deleteTotal": "Total deleted",
"userCount": "Registered users", "userCount": "Registered users",
"uploadBytes": "Total upload size", "uploadBytes": "Total upload size",
"folderDistribution": "Uploads by folder", "sourceDistribution": "Uploads by source",
"folderUsage": "Storage by folder", "sourceWeb": "Web",
"sourceApi": "API",
"noData": "No data", "noData": "No data",
"folderCount": "{count} · {size}",
"gallery": "Gallery", "gallery": "Gallery",
"folder": "Folder",
"filterStorageAll": "All storage", "filterStorageAll": "All storage",
"storage": "Storage", "storage": "Storage",
"searchPlaceholder": "Search filename", "searchPlaceholder": "Search filename",
@@ -331,12 +336,11 @@
"deletePartialFail": "{n} image(s) failed to delete", "deletePartialFail": "{n} image(s) failed to delete",
"deleteFailed": "Delete failed", "deleteFailed": "Delete failed",
"deletedSingle": "Deleted", "deletedSingle": "Deleted",
"allFolders": "All",
"counting": "Counting…", "counting": "Counting…",
"summarySearch": "{folder} · Search “{q}” · {total} images", "summarySearch": "Search “{q}” · {total} images",
"summaryTotal": "{folder} · {total} images", "summaryTotal": "{total} images",
"summarySearchStorage": "{folder} · {storage} · Search “{q}” · {total} images", "summarySearchStorage": "{storage} · Search “{q}” · {total} images",
"summaryTotalStorage": "{folder} · {storage} · {total} images" "summaryTotalStorage": "{storage} · {total} images"
}, },
"logs": { "logs": {
"pageTitle": "Activity log", "pageTitle": "Activity log",
@@ -354,7 +358,6 @@
"actionDelete": "Delete", "actionDelete": "Delete",
"sourceWeb": "Web", "sourceWeb": "Web",
"sourceApi": "API", "sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "Time", "colTime": "Time",
"colUser": "User", "colUser": "User",
"colStorage": "Storage", "colStorage": "Storage",
@@ -382,7 +385,9 @@
"first": "First page", "first": "First page",
"prev": "Previous page", "prev": "Previous page",
"next": "Next page", "next": "Next page",
"last": "Last page" "last": "Last page",
"goTo": "Go",
"pagePlaceholder": "Page"
}, },
"api": { "api": {
"tokenTitle": "API Token", "tokenTitle": "API Token",
@@ -413,15 +418,11 @@
"docs": { "docs": {
"upload": { "upload": {
"title": "Upload images", "title": "Upload images",
"descAdmin": "Upload one or more images. Use image field; folder creates directory if missing. Also accepts file, files, type.", "desc": "Upload one or more images via image field. Also accepts file, files. Stored under images/."
"descUser": "Upload one or more images via image field. Also accepts file, files. Stored under images/.",
"curlCustomFolder": "# Upload to custom folder blog/"
}, },
"list": { "list": {
"title": "List images", "title": "List images",
"descAdmin": "Paginated gallery; optional folder filter. limit default 20, max 100.", "desc": "Paginated gallery. limit default 20, max 100."
"descUser": "Paginated list of your images. limit default 20, max 100.",
"curlFolderFilter": "# Filter by folder"
}, },
"search": { "search": {
"title": "Search images", "title": "Search images",
@@ -437,7 +438,7 @@
}, },
"twikoo": { "twikoo": {
"title": "Twikoo-compatible upload", "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."
} }
} }
}, },
+30 -29
View File
@@ -64,6 +64,8 @@
"loginFailedSecret": "登录失败,请检查密钥", "loginFailedSecret": "登录失败,请检查密钥",
"loginFailedCredentials": "用户名或密码错误", "loginFailedCredentials": "用户名或密码错误",
"loginFailed": "登录失败,请检查用户名和密码", "loginFailed": "登录失败,请检查用户名和密码",
"captchaHint": "拖动滑块对齐缺口",
"captchaSuccess": "验证通过",
"registerFailed": "注册失败,请检查输入", "registerFailed": "注册失败,请检查输入",
"setupFailed": "初始化失败,请检查输入", "setupFailed": "初始化失败,请检查输入",
"migrateFailed": "迁移失败,请检查输入", "migrateFailed": "迁移失败,请检查输入",
@@ -88,7 +90,7 @@
"domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892", "domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
"domainSeparationProxyExample": "查看示例", "domainSeparationProxyExample": "查看示例",
"siteBaseUrl": "网站域名", "siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址", "siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com", "siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "图片域名", "imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址", "imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
@@ -179,18 +181,16 @@
"settings": { "settings": {
"title": "系统设置", "title": "系统设置",
"subtitle": "保存后写入数据库;环境变量优先级更高", "subtitle": "保存后写入数据库;环境变量优先级更高",
"uploadAccount": "上传与账号", "featureToggles": "功能开关",
"defaultFolder": "默认上传目录",
"defaultFolderHint": "保存在本机浏览器,仅影响您的上传目标目录",
"allowRegistration": "开放用户注册", "allowRegistration": "开放用户注册",
"allowRegistrationHint": "允许新用户自行注册(普通用户仅可见自己的图片)", "allowRegistrationHint": "允许新用户自行注册(普通用户仅可见自己的图片)",
"accessControl": "访问控制", "accessControl": "访问控制",
"refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制", "refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制",
"refererPlaceholder": "example.com, www.example.com", "refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "域名与链接", "domainSettings": "域名配置",
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API", "domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
"siteBaseUrl": "网站域名", "siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址", "siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com", "siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "当前生效:{url}", "siteBaseUrlActive": "当前生效:{url}",
"imageBaseUrl": "图片域名", "imageBaseUrl": "图片域名",
@@ -198,11 +198,17 @@
"imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名", "imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名",
"imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "当前生效:{url}", "imageBaseUrlActive": "当前生效:{url}",
"hideFolderInUrl": "隐藏直链目录前缀", "runtimeDetected": "检测到当前访问:{url}(不会自动写入配置)",
"hideFolderInUrlHint": "开启后链接为 /2026/08/xxx.webp,不含 images/ 等目录名;旧链接仍可用", "fillDetectedOrigin": "填入检测地址",
"hideFolderConfirmTitle": "确认隐藏目录前缀?", "hostRoleUnknown": "当前访问 Host 与配置不一致,可能存在反代绕过风险",
"hideFolderConfirmDesc": "开启后不同目录下若存在相同年月与文件名(如 images/2026/08/a.webp 与 blog/2026/08/a.webp),短链接 /2026/08/a.webp 将优先解析到 images 目录,可能访问到非预期的图片。仅单一上传目录或文件名全局唯一时建议开启。", "disableDomainSeparationTitle": "关闭域名分离",
"hideFolderConfirmEnable": "仍要开启", "disableDomainSeparationDesc": "将清除管理域名配置,仅保留图片域名。确定继续?",
"disableDomainSeparationConfirm": "确认关闭",
"domainSeparationRequired": "启用域名分离时需同时填写网站域名与图片域名",
"hideFolderInUrl": "使用简短图片链接",
"hideFolderInUrlHint": "开启后链接仅含域名与文件名,例如 img.example.com/abc.webp;关闭后保留日期或自定义目录,例如 img.example.com/2026/08/abc.webp",
"storageUseDatePath": "上传目录按年/月分组",
"storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传",
"badgeEnv": ".env", "badgeEnv": ".env",
"badgeSaved": "已保存", "badgeSaved": "已保存",
"badgeDefault": "默认", "badgeDefault": "默认",
@@ -312,12 +318,11 @@
"deleteTotal": "累计删除", "deleteTotal": "累计删除",
"userCount": "注册用户数量", "userCount": "注册用户数量",
"uploadBytes": "累计上传体积", "uploadBytes": "累计上传体积",
"folderDistribution": "上传目录分布", "sourceDistribution": "上传来源分布",
"folderUsage": "目录占用", "sourceWeb": "网页",
"sourceApi": "API",
"noData": "暂无数据", "noData": "暂无数据",
"folderCount": "{count} 张 · {size}",
"gallery": "图库记录", "gallery": "图库记录",
"folder": "目录",
"filterStorageAll": "全部存储", "filterStorageAll": "全部存储",
"storage": "存储", "storage": "存储",
"searchPlaceholder": "搜索文件名", "searchPlaceholder": "搜索文件名",
@@ -331,12 +336,11 @@
"deletePartialFail": "{n} 张图片删除失败", "deletePartialFail": "{n} 张图片删除失败",
"deleteFailed": "删除失败", "deleteFailed": "删除失败",
"deletedSingle": "已删除", "deletedSingle": "已删除",
"allFolders": "全部",
"counting": "统计中…", "counting": "统计中…",
"summarySearch": "{folder} · 搜索「{q}」· 共 {total} 张", "summarySearch": "搜索「{q}」· 共 {total} 张",
"summaryTotal": "{folder} · 共 {total} 张", "summaryTotal": "共 {total} 张",
"summarySearchStorage": "{folder} · {storage} · 搜索「{q}」· 共 {total} 张", "summarySearchStorage": "{storage} · 搜索「{q}」· 共 {total} 张",
"summaryTotalStorage": "{folder} · {storage} · 共 {total} 张" "summaryTotalStorage": "{storage} · 共 {total} 张"
}, },
"logs": { "logs": {
"pageTitle": "操作日志", "pageTitle": "操作日志",
@@ -354,7 +358,6 @@
"actionDelete": "删除", "actionDelete": "删除",
"sourceWeb": "网页", "sourceWeb": "网页",
"sourceApi": "API", "sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "时间", "colTime": "时间",
"colUser": "用户", "colUser": "用户",
"colStorage": "存储", "colStorage": "存储",
@@ -382,7 +385,9 @@
"first": "第一页", "first": "第一页",
"prev": "上一页", "prev": "上一页",
"next": "下一页", "next": "下一页",
"last": "最后一页" "last": "最后一页",
"goTo": "跳转",
"pagePlaceholder": "页码"
}, },
"api": { "api": {
"tokenTitle": "API Token", "tokenTitle": "API Token",
@@ -413,15 +418,11 @@
"docs": { "docs": {
"upload": { "upload": {
"title": "上传图片", "title": "上传图片",
"descAdmin": "上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。", "desc": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片存入 images/ 目录。"
"descUser": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。",
"curlCustomFolder": "# 上传到自定义目录 blog/"
}, },
"list": { "list": {
"title": "获取图片列表", "title": "获取图片列表",
"descAdmin": "分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。", "desc": "分页列出图库,limit 默认 20、最大 100。"
"descUser": "分页列出您的图库,limit 默认 20、最大 100。",
"curlFolderFilter": "# 按目录筛选"
}, },
"search": { "search": {
"title": "搜索图片", "title": "搜索图片",
@@ -437,7 +438,7 @@
}, },
"twikoo": { "twikoo": {
"title": "twikoo 兼容上传", "title": "twikoo 兼容上传",
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。" "desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。"
} }
} }
}, },
+2984 -2
View File
File diff suppressed because it is too large. Load diff
+10 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.1.3", "version": "1.2.3",
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme", "homepage": "https://github.com/O96u/PicHost#readme",
"repository": { "repository": {
@@ -33,7 +33,12 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"start": "node .output/server/index.mjs", "start": "node .output/server/index.mjs",
"reset-password": "node server/cli/reset-password.mjs" "reset-password": "node server/cli/reset-password.mjs",
"migrate": "node server/cli/migrate-to-single-images.mjs",
"docs:prepare": "node docs-site/copy-screenshots.mjs",
"docs:dev": "npm run docs:prepare && vitepress dev docs-site",
"docs:build": "npm run docs:prepare && vitepress build docs-site",
"docs:preview": "npm run docs:prepare && vitepress preview docs-site"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.876.0", "@aws-sdk/client-s3": "^3.876.0",
@@ -48,7 +53,10 @@
"@nuxt/eslint": "^1.16.0", "@nuxt/eslint": "^1.16.0",
"@types/node": "^22.15.0", "@types/node": "^22.15.0",
"eslint": "^10.6.0", "eslint": "^10.6.0",
"mermaid": "^11.17.2",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitepress": "^1.6.4",
"vitepress-plugin-mermaid": "^2.0.17",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"vue-tsc": "^3.3.5" "vue-tsc": "^3.3.5"
} }
+6
View File
@@ -0,0 +1,6 @@
import { createLoginCaptchaChallenge } from '../../utils/login-captcha'
export default defineEventHandler(() => {
const { id, svg } = createLoginCaptchaChallenge()
return { id, svg }
})
+19
View File
@@ -0,0 +1,19 @@
import { createApiError } from '../../../utils/api-error'
import { confirmLoginCaptcha } from '../../../utils/login-captcha'
interface ConfirmBody {
id?: string
captchaPosition?: number
}
export default defineEventHandler(async (event) => {
const body = await readBody<ConfirmBody>(event)
const id = body?.id?.trim() ?? ''
const position = Number(body?.captchaPosition)
if (!id || !confirmLoginCaptcha(id, position)) {
createApiError(event, 'INVALID_REQUEST', '滑块未对齐,请重试', 400)
}
return { success: true }
})
+13
View File
@@ -1,3 +1,4 @@
import type { H3Event } from 'h3'
import { import {
checkAdminSecret, checkAdminSecret,
setLegacyAuthCookie setLegacyAuthCookie
@@ -10,12 +11,15 @@ import {
} from '../../utils/auth' } from '../../utils/auth'
import { findUserByUsername } from '../../utils/db' import { findUserByUsername } from '../../utils/db'
import { getAdminSecret } from '../../utils/env' import { getAdminSecret } from '../../utils/env'
import { verifyLoginCaptcha } from '../../utils/login-captcha'
import { clientIp, logInfo, logWarn } from '../../utils/logger' import { clientIp, logInfo, logWarn } from '../../utils/logger'
interface LoginBody { interface LoginBody {
username?: string username?: string
password?: string password?: string
secret?: string secret?: string
captchaId?: string
captchaPosition?: number
} }
const loginAttempts = new Map<string, { count: number, resetAt: number }>() const loginAttempts = new Map<string, { count: number, resetAt: number }>()
@@ -38,11 +42,20 @@ function checkRateLimit(ip: string): void {
} }
} }
function verifyCaptchaOrFail(event: H3Event, body: LoginBody) {
const id = body.captchaId?.trim() ?? ''
const position = Number(body.captchaPosition)
if (!id || !verifyLoginCaptcha(id, position)) {
createApiError(event, 'INVALID_REQUEST', '请先完成滑块验证', 400)
}
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const ip = clientIp(event) const ip = clientIp(event)
checkRateLimit(ip) checkRateLimit(ip)
const body = await readBody<LoginBody>(event) const body = await readBody<LoginBody>(event)
verifyCaptchaOrFail(event, body ?? {})
const initialized = isInitialized() const initialized = isInitialized()
if (!initialized) { if (!initialized) {
+42 -6
View File
@@ -3,8 +3,9 @@ import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } fr
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { MAX_DELETE_BATCH } from '../../utils/constants' import { MAX_DELETE_BATCH } from '../../utils/constants'
import { insertActivityLog } from '../../utils/db' import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key' import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
import { deleteImage, headImage } from '../../utils/storage' import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
import { logInfo } from '../../utils/logger' import { logInfo } from '../../utils/logger'
interface BatchDeleteBody { interface BatchDeleteBody {
@@ -33,7 +34,11 @@ export default defineEventHandler(async (event) => {
} }
for (const key of keys) { for (const key of keys) {
if (!validateImageKey(key)) { if (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
) {
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400) createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400)
} }
} }
@@ -43,7 +48,38 @@ export default defineEventHandler(async (event) => {
for (const key of keys) { for (const key of keys) {
try { 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) { if (!existing) {
failed.push({ failed.push({
@@ -63,7 +99,7 @@ export default defineEventHandler(async (event) => {
continue continue
} }
await deleteImage(key) await deleteImage(storageKey, key)
deleted.push(key) deleted.push(key)
insertActivityLog({ insertActivityLog({
@@ -77,7 +113,7 @@ export default defineEventHandler(async (event) => {
backendId: existing.backendId ?? null backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key, size: existing.size }) logInfo('delete success', { key: storageKey, size: existing.size })
} catch { } catch {
failed.push({ failed.push({
key, key,
+1 -4
View File
@@ -1,5 +1,4 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access' import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query' import { readBackendIdQuery } from '../../utils/image-query'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { countImages } from '../../utils/storage' import { countImages } from '../../utils/storage'
@@ -9,14 +8,12 @@ export default defineEventHandler(async (event) => {
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const query = getQuery(event) const query = getQuery(event)
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
const backendId = readBackendIdQuery(query) const backendId = readBackendIdQuery(query)
if (backendId === null) { if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400) createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
} }
const total = await countImages(folder, userFilter, backendId) const total = await countImages(userFilter, backendId)
return { total } return { total }
}) })
+39 -6
View File
@@ -2,8 +2,9 @@ import type { DeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access' import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { insertActivityLog } from '../../utils/db' import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key' import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
import { deleteImage, headImage } from '../../utils/storage' import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
import { logInfo } from '../../utils/logger' import { logInfo } from '../../utils/logger'
interface DeleteBody { interface DeleteBody {
@@ -19,11 +20,43 @@ export default defineEventHandler(async (event) => {
const key = (typeof query.key === 'string' ? query.key : undefined) const key = (typeof query.key === 'string' ? query.key : undefined)
?? body?.key ?? body?.key
if (!key || !validateImageKey(key)) { if (
!key
|| (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
)
) {
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400) 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) { if (!existing) {
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404) createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
} }
@@ -31,7 +64,7 @@ export default defineEventHandler(async (event) => {
await assertImageOwnership(event, existing.userId) await assertImageOwnership(event, existing.userId)
try { try {
await deleteImage(key) await deleteImage(storageKey, key)
} catch { } catch {
createApiError(event, 'DELETE_FAILED', '删除失败', 500) createApiError(event, 'DELETE_FAILED', '删除失败', 500)
} }
@@ -47,7 +80,7 @@ export default defineEventHandler(async (event) => {
backendId: existing.backendId ?? null backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key, size: existing.size }) logInfo('delete success', { key: storageKey, size: existing.size })
const response: DeleteResponse = { success: true } const response: DeleteResponse = { success: true }
return response return response
+1 -9
View File
@@ -9,16 +9,9 @@ import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends' import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query' import { readBackendIdQuery } from '../../utils/image-query'
import { listImages } from '../../utils/storage' import { listImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
if (!raw || raw === 'all') return undefined
return isValidFolderName(raw) ? raw : undefined
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event) await requireApiOrAdminAuth(event)
@@ -34,14 +27,13 @@ export default defineEventHandler(async (event) => {
? Math.floor(pageRaw) ? Math.floor(pageRaw)
: 1 : 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query) const backendId = readBackendIdQuery(query)
if (backendId === null) { if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400) createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
} }
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, folder, userFilter, backendId }) const listing = await listImages({ limit, page, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
+1 -9
View File
@@ -9,16 +9,9 @@ import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends' import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query' import { readBackendIdQuery } from '../../utils/image-query'
import { searchImages } from '../../utils/storage' import { searchImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
if (!raw || raw === 'all') return undefined
return isValidFolderName(raw) ? raw : undefined
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event) await requireApiOrAdminAuth(event)
@@ -43,14 +36,13 @@ export default defineEventHandler(async (event) => {
? Math.floor(pageRaw) ? Math.floor(pageRaw)
: 1 : 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query) const backendId = readBackendIdQuery(query)
if (backendId === null) { if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400) createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
} }
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, folder, userFilter, backendId }) const result = await searchImages({ query: q, limit, page, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
+1 -46
View File
@@ -1,6 +1,6 @@
import { readMultipartFormData } from 'h3' import { readMultipartFormData } from 'h3'
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image' import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId, canUseCustomUploadFolder } from '../../utils/access' import { requireUploadAuth, verifyApiUploadToken, getUploadUserId } from '../../utils/access'
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants' import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
import { import {
detectMimeFromSignature, detectMimeFromSignature,
@@ -11,22 +11,8 @@ import {
processSingleImageUpload processSingleImageUpload
} from '../../utils/process-image-upload' } from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import {
DEFAULT_FOLDER,
isValidFolderName,
normalizeFolderName
} from '../../utils/image-key'
import { checkUploadRateLimit } from '../../utils/rate-limit' import { checkUploadRateLimit } from '../../utils/rate-limit'
function readFormText(
formData: { name?: string, data?: Buffer | Uint8Array }[],
name: string
): string {
const part = formData.find(item => item.name === name && item.data?.length)
if (!part?.data) return ''
return new TextDecoder().decode(part.data).trim()
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireUploadAuth(event) await requireUploadAuth(event)
@@ -44,36 +30,6 @@ export default defineEventHandler(async (event) => {
const source = verifyApiUploadToken(event) ? 'api' : 'web' const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event) const uploadUserId = await getUploadUserId(event)
const query = getQuery(event)
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
const folderFromQuery = typeof query.folder === 'string'
? query.folder
: typeof query.type === 'string'
? query.type
: ''
const requestedFolder = normalizeFolderName(folderFromForm || folderFromQuery)
const canCustomFolder = await canUseCustomUploadFolder(event)
if (!canCustomFolder && requestedFolder && requestedFolder !== DEFAULT_FOLDER) {
createApiError(
event,
'FORBIDDEN',
'当前账号不支持自定义上传目录',
403
)
}
const folder = canCustomFolder ? requestedFolder : ''
if (folder && !isValidFolderName(folder)) {
createApiError(
event,
'INVALID_REQUEST',
'无效的 folder:仅允许字母数字开头,可含 - _,最长 32 位,且不能是 api/stats 等保留名',
400
)
}
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图) // 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter( const fileParts = formData.filter(
part => (part.name === 'image' || part.name === 'file' || part.name === 'files') part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
@@ -127,7 +83,6 @@ export default defineEventHandler(async (event) => {
bytes, bytes,
filename: part.filename, filename: part.filename,
source, source,
prefix: folder || DEFAULT_FOLDER,
userId: uploadUserId userId: uploadUserId
}) })
+1 -1
View File
@@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
: undefined : undefined
const sourceRaw = typeof query.source === 'string' ? query.source : '' const sourceRaw = typeof query.source === 'string' ? query.source : ''
const source = (sourceRaw === 'web' || sourceRaw === 'api' || sourceRaw === 'twikoo') const source = (sourceRaw === 'web' || sourceRaw === 'api')
? sourceRaw as LogSource ? sourceRaw as LogSource
: undefined : undefined
+43 -17
View File
@@ -5,6 +5,7 @@ import {
setSetting, setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS, SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_HIDE_FOLDER_IN_URL, SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL, SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL, SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY, SETTINGS_WEBP_QUALITY,
@@ -22,7 +23,7 @@ import {
parseAutoDeleteDays, parseAutoDeleteDays,
parseWebpQuality, parseWebpQuality,
setGlobalAutoDeletePolicy, setGlobalAutoDeletePolicy,
validateDomainSeparationPair validateSettingsDomainPatch
} from '../../utils/env' } from '../../utils/env'
interface SettingsPatchBody { interface SettingsPatchBody {
@@ -30,7 +31,9 @@ interface SettingsPatchBody {
allowedRefererHosts?: unknown allowedRefererHosts?: unknown
siteBaseUrl?: unknown siteBaseUrl?: unknown
imageBaseUrl?: unknown imageBaseUrl?: unknown
domainSeparation?: unknown
hideFolderInUrl?: unknown hideFolderInUrl?: unknown
storageUseDatePath?: unknown
autoDeleteDays?: unknown autoDeleteDays?: unknown
allowRegistration?: unknown allowRegistration?: unknown
} }
@@ -69,6 +72,11 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized) setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized)
} }
const existingSite = normalizeSiteBaseUrl(getSetting(SETTINGS_SITE_BASE_URL) ?? '')
const existingImage = normalizeImageBaseUrl(getSetting(SETTINGS_IMAGE_BASE_URL) ?? '')
let nextSite = existingSite
let nextImage = existingImage
if (body.siteBaseUrl !== undefined) { if (body.siteBaseUrl !== undefined) {
const configured = normalizeSiteBaseUrl( const configured = normalizeSiteBaseUrl(
typeof body.siteBaseUrl === 'string' ? body.siteBaseUrl : '' typeof body.siteBaseUrl === 'string' ? body.siteBaseUrl : ''
@@ -77,11 +85,11 @@ export default defineEventHandler(async (event) => {
createApiError( createApiError(
event, event,
'INVALID_REQUEST', 'INVALID_REQUEST',
'SITE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名', 'SITE_BASE_URL 需为 http(s) 地址,且末尾不加 /',
400 400
) )
} }
setSetting(SETTINGS_SITE_BASE_URL, configured) nextSite = configured
} }
if (body.imageBaseUrl !== undefined) { if (body.imageBaseUrl !== undefined) {
@@ -96,22 +104,36 @@ export default defineEventHandler(async (event) => {
400 400
) )
} }
setSetting(SETTINGS_IMAGE_BASE_URL, configured) nextImage = configured
} }
if (body.siteBaseUrl !== undefined || body.imageBaseUrl !== undefined) { const domainSeparationProvided = body.domainSeparation !== undefined
const siteConfigured = normalizeSiteBaseUrl( const wantSeparation = domainSeparationProvided
getSetting(SETTINGS_SITE_BASE_URL) ?? '' ? Boolean(body.domainSeparation)
) : null
const imageConfigured = normalizeImageBaseUrl(
getSetting(SETTINGS_IMAGE_BASE_URL) ?? '' const patchError = validateSettingsDomainPatch({
) existingSite,
if (siteConfigured && imageConfigured) { existingImage,
const pairError = validateDomainSeparationPair(siteConfigured, imageConfigured) nextSite,
if (pairError) { nextImage,
createApiError(event, 'INVALID_REQUEST', pairError, 400) wantSeparation,
} siteBaseUrlProvided: body.siteBaseUrl !== undefined
} })
if (patchError) {
createApiError(event, 'INVALID_REQUEST', patchError, 400)
}
if (wantSeparation === false) {
nextSite = ''
}
if (body.siteBaseUrl !== undefined || body.domainSeparation !== undefined) {
setSetting(SETTINGS_SITE_BASE_URL, nextSite)
}
if (body.imageBaseUrl !== undefined) {
setSetting(SETTINGS_IMAGE_BASE_URL, nextImage)
} }
if (body.autoDeleteDays !== undefined) { if (body.autoDeleteDays !== undefined) {
@@ -135,5 +157,9 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_HIDE_FOLDER_IN_URL, body.hideFolderInUrl ? 'true' : 'false') setSetting(SETTINGS_HIDE_FOLDER_IN_URL, body.hideFolderInUrl ? 'true' : 'false')
} }
if (body.storageUseDatePath !== undefined) {
setSetting(SETTINGS_STORAGE_USE_DATE_PATH, body.storageUseDatePath ? 'true' : 'false')
}
return getSettingsPayload(event) return getSettingsPayload(event)
}) })
+1 -1
View File
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
uploadTotal: scoped.storedCount, uploadTotal: scoped.storedCount,
deleteTotal: 0, deleteTotal: 0,
uploadBytesTotal: scoped.uploadBytesTotal, uploadBytesTotal: scoped.uploadBytesTotal,
bySource: { web: scoped.storedCount, api: 0, twikoo: 0 }, bySource: { web: scoped.storedCount, api: 0 },
byFolderUploads: scoped.byFolder.map(item => ({ byFolderUploads: scoped.byFolder.map(item => ({
folder: item.folder, folder: item.folder,
count: item.count count: item.count
+166
View File
@@ -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)
})
+13 -2
View File
@@ -1,7 +1,18 @@
import { shouldBlockImageHostRequest } from '../utils/host-isolation' import {
shouldBlockImageHostRequest,
shouldBlockSiteHostImageRequest,
shouldBlockUnknownHostRequest
} from '../utils/host-isolation'
const HOST_ISOLATION_CACHE_CONTROL = 'private, no-cache, max-age=0'
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
if (shouldBlockImageHostRequest(event)) { if (
shouldBlockUnknownHostRequest(event)
|| shouldBlockImageHostRequest(event)
|| shouldBlockSiteHostImageRequest(event)
) {
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
throw createError({ statusCode: 404, statusMessage: 'Not Found' }) throw createError({ statusCode: 404, statusMessage: 'Not Found' })
} }
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image' import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image'
/** /**
* 仅当路径像图片 key({type}/YYYY/MM/file.ext)时才接管。 * 仅当路径像图片 key 或短链时才接管。
* 其它请求(/_nuxt、/api、页面)原样放行。 * 其它请求(/_nuxt、/api、页面)原样放行。
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { clientIp, logError, logInfo, logWarn } from '../utils/logger' import { clientIp, logError, logInfo, logWarn } from '../utils/logger'
/** /**
* 只记录 /api/*,避免 /images、/twikoo 直链刷屏。 * 只记录 /api/*,避免 /images 直链刷屏。
*/ */
export default defineNitroPlugin((nitroApp) => { export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', (event) => { nitroApp.hooks.hook('request', (event) => {
+57 -14
View File
@@ -1,6 +1,6 @@
import { getDataDir } from '../utils/data-dir' import { getDataDir } from '../utils/data-dir'
import { runAutoDeleteCleanup } from '../utils/auto-delete' import { runAutoDeleteCleanup } from '../utils/auto-delete'
import { migrateLocalImagesToIndex } from '../utils/image-index' import { migrateLocalImagesToIndex, purgeOrphanImageIndexRows, reconcileLegacyImageKeys, repairLocalImageIndexMetadata } from '../utils/image-index'
import { ensureDefaultBackends } from '../utils/storage-backends' import { ensureDefaultBackends } from '../utils/storage-backends'
import { logException, logInfo } from '../utils/logger' import { logException, logInfo } from '../utils/logger'
@@ -16,19 +16,62 @@ export default defineNitroPlugin(() => {
}) })
ensureDefaultBackends() 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) => { void (async () => {
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) { const dataDir = getDataDir()
logInfo('startup auto-delete', { ...result }) 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, '无法识别的图片格式') return easyImageError(415, '无法识别的图片格式')
} }
// Twikoo 评论图片单独存放在 twikoo/ 目录下 // Twikoo 走 token 上传,记入 activity log source=api
const result = await processSingleImageUpload(event, { const result = await processSingleImageUpload(event, {
bytes, bytes,
filename: imagePart.filename, filename: imagePart.filename,
prefix: 'twikoo', source: 'api'
source: 'twikoo'
}) })
if ('error' in result) { 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) const user = await getCurrentUser(event)
return user?.id ?? null return user?.id ?? null
} }
/** 管理员会话 / 全局 API Token 可使用自定义上传目录 */
export async function canUseCustomUploadFolder(event: H3Event): Promise<boolean> {
if (devBypass(event)) {
return true
}
if (verifyGlobalApiUploadToken(event)) {
return true
}
if (resolveUserIdFromApiToken(event) !== null) {
return false
}
const user = await getCurrentUser(event)
return user?.role === 'admin'
}
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more