diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d172262..4f98b7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,6 @@ jobs: - name: Test run: npm run test + + - name: Build docs + run: npm run docs:build diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..69673c9 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,59 @@ +name: docs + +on: + push: + branches: [main] + paths: + - 'docs-site/**' + - 'docs/screenshots/**' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + 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 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 5a87dc4..02ba01a 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ git-hooks/ # 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库) scripts/ dist-fpk/ + +# VitePress docs site +docs-site/.vitepress/cache +docs-site/.vitepress/dist +docs-site/public/screenshots diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c5ab9..d6a7783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 +## [Unreleased] + +## [1.2.1] - 2026-08-29 + +### 新增 + +- 登录滑动拼图验证(缺口对齐);验证与登录分两步,登录接口校验 `captchaId` / `captchaPosition` +- VitePress 文档站(`docs-site/`):中英文指南,部署至 GitHub Pages +- `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 ### 变更 @@ -13,7 +36,7 @@ - CLI 迁移工具:`docker exec pichost migrate` / `migrate --apply`(`server/cli/migrate-to-single-images.mjs`) - 扫描 `data/` 下除 `images` 外所有顶层目录中的图片,输出 `data/mapping.json` - 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录),并打印同步日志 -- 迁移文档:[`docs/migration-to-v1.2.md`](docs/migration-to-v1.2.md) +- 迁移文档:[文档站 · v1.2 迁移](https://o96u.github.io/PicHost/guide/migration) ### 改进 @@ -52,7 +75,7 @@ - `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域 - 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示) - 设置页加载时检测 GitHub 最新版本并提示更新 -- 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):`docs/domain-separation.md` +- 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):[文档站 · 双域名分离](https://o96u.github.io/PicHost/guide/domain-separation) ### 改进 diff --git a/README.en.md b/README.en.md index 274ddaf..4931bbc 100644 --- a/README.en.md +++ b/README.en.md @@ -17,64 +17,38 @@

- Branches · - Quick Start · + Docs · Screenshots · Features · - Tech Stack · - Roadmap · + Quick Start · 简体中文

--- -## Branches - -| Branch | Notes | -| ------ | ----- | -| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.2.0** (in progress) — unified `images/` storage, multi-backend, dual-domain isolation, [`/storage`](docs/README.md), [`migration guide`](docs/migration-to-v1.2.en.md) | -| [**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 -| Upload | Storage | -| :------------------------------------: | :--------------------------------: | -| ![Upload](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) | +**main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc) -| Stats & gallery | Settings | -| :----------------------------------: | :--------------------------------: | -| ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings.png) | +| Home | Storage | +| :--: | :-----: | +| ![Home](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) | + +| Stats | Settings | +| :---: | :-------: | +| ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings-en.png) | ## Features -- **Drag, click, or Ctrl+V paste** to upload; all files under `images/` -- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL -- **Multi-user** — username/password login; optional registration; users see only their images -- **Upload preferences** (home card flip): client-side compression, auto-copy links, per-user auto-delete -- **Multi-backend storage**: local disk + S3-compatible (Cloudflare R2 / Tencent COS / Alibaba OSS / AWS S3); admins manage backends at **Storage** (`/storage`) -- **Hybrid URLs**: `proxy` (same-origin via PicHost) or `public` (302 to CDN / bucket URL) -- **Gallery** — browse, search, batch delete; admin sees uploader badges -- **Stats** — upload/delete metrics, upload source breakdown; admin sees registered user count -- **API** — global token (admin) + per-user tokens; in-app cURL snippets -- **Twikoo** — `POST /api/index.php` (`image` + `token`) -- **Zero-config Docker** — first-run web wizard, no secrets required upfront +- **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection +- **Multi-user** — login with slider captcha, optional registration; users see only their images +- **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs +- **Gallery & stats** — browse, search, batch delete; upload trends and source breakdown +- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible +- **Zero-config Docker** — first-run web wizard, no secrets upfront ## Quick Start -### Docker (recommended) - ```bash docker run -d \ --name pichost \ @@ -84,184 +58,14 @@ docker run -d \ muxui/pichost:latest ``` -Default port: **6892**. Open `http://:6892` in your browser and complete the setup wizard. +Open `http://: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 -docker exec pichost reset-password # admin only, when there is exactly one -docker exec pichost reset-password # admin or regular user -``` - -A random password is printed. Fails if the user does not exist. - -Local: `npm run reset-password` or `npm run reset-password -- ` - -### Upgrading to v1.2.0 (legacy local directories) - -From v1.2.0, all images live under `data/images/`. Migration is two steps: - -1. **CLI** (manual): move images from every top-level folder under `data/` except `images` into `data/images/{original-dir}/...` -2. **Startup sync** (automatic): scan disk, fill SQLite index, normalize legacy keys, remove orphan rows - -```bash -docker exec pichost migrate # preview + data/mapping.json -docker exec pichost migrate --apply # move files -# upgrade image and restart — index sync logs appear on startup -``` - -Local: `npm run migrate` / `npm run migrate -- --apply`. - -Full guide (layout, logs, URLs, **other image hosts**): [`docs/migration-to-v1.2.en.md`](docs/migration-to-v1.2.en.md) - -Skip the CLI if you only use object storage or files are already under `data/images/`. - -If you cloned the repo, you can also use `docker compose up -d` (see `docker-compose.yml`). - -### Local development - -```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 | - -All uploads are stored under `images/`. - -## Storage layout - -``` -/data -├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index -└── images/ # All images (local backend); may include migrated subpaths -``` - -New uploads: `images/randomId.webp` or `images/YYYY/MM/randomId.webp`. Migrated legacy paths may look like `images/blog/...`. Metadata lives in SQLite `images`. With cloud backends, blobs live in the bucket. Back up `data/images/`, `pichost.db`, and bucket data. - -Upgrading from v1.1.x with parallel folders such as `data/blog/`: see [Upgrading to v1.2.0](#upgrading-to-v120-legacy-local-directories) or [`docs/migration-to-v1.2.en.md`](docs/migration-to-v1.2.en.md). - -## API overview - -| 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 "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://: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 -npm run reset-password # Reset password (optional username) -npm run migrate # Preview legacy dir migration (-- --apply to run) -``` - -## Roadmap - -| Version | Plan | -| ---------- | -------------------------------------------------------------- | -| **v1.2.0** | Unified `images/` storage, CLI migration, startup index sync, upload source stats (in progress) | -| **v1.1.5** | Gallery preview reliability: auto-retry, storage verify, no-cache errors | -| **v1.1.4** | Flat vs date storage paths, basename public URLs, bidirectional domain isolation | -| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check | -| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits | -| **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 | +| Branch | Notes | +| ------ | ----- | +| [**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) | ## License diff --git a/README.md b/README.md index c0c8d30..8a3d8cd 100644 --- a/README.md +++ b/README.md @@ -17,64 +17,38 @@

- 分支 · - 快速开始 · + 文档 · 预览 · 特性 · - 技术栈 · - 路线图 · + 快速开始 · English

--- -## 分支 - -| 分支 | 说明 | -| ---- | ---- | -| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.2.0**(开发中)— 统一 `images/` 存储、多后端、双域名隔离、[`/storage`](docs/README.md)、[`迁移指南`](docs/migration-to-v1.2.md) | -| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 | - -日常使用、需要管理多种存储后端,请直接用 **main**。 - -若图床**只接 Cloudflare R2**、希望更简化的 R2 专用版本,可切换分支: - -```bash -git clone https://github.com/O96u/PicHost.git -cd PicHost -git checkout cloudflare -``` - ---- - ## 界面预览 -| 上传 | 存储管理 | -| :-----------------------------------: | :-------------------------------------: | -| ![上传](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) | +**main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc) -| 统计与图库 | 系统设置 | -| :---------------------------------: | :-------------------------------------: | +| 首页 | 存储 | +| :--: | :--: | +| ![首页](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) | + +| 统计 | 设置 | +| :--: | :--: | | ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) | ## 特性 -- **拖拽 / 点击 / Ctrl+V 粘贴**上传;图片统一存入 `images/` -- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名 -- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片 -- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除 -- **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端 -- **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址) -- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签 -- **统计**:上传/删除趋势、上传来源分布;管理员额外显示注册用户数量 -- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL -- **Twikoo**:`POST /api/index.php`(`image` + `token`) -- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥 +- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链 +- **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片 +- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public` +- **图库与统计**:浏览、搜索、批量删除;上传趋势与来源分布 +- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容 +- **Docker 零配置**:首次访问 Web 引导创建管理员 ## 快速开始 -### Docker(推荐) - ```bash docker run -d \ --name pichost \ @@ -84,191 +58,14 @@ docker run -d \ 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 - -# 指定用户名:可重置管理员或普通用户 -docker exec pichost reset-password 用户名 -``` - -终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。 - -本地:`npm run reset-password` 或 `npm run reset-password -- 用户名` - -### 升级到 v1.2.0(本地磁盘遗留目录) - -v1.2.0 起图片统一在 `data/images/`。迁移分两步: - -1. **CLI 搬文件**(手动,升级前后均可):把 `data/` 下除 `images` 外各顶层目录里的图片迁入 `data/images/{原目录名}/...` -2. **启动时同步索引**(自动):扫描 `data/images/` 补全 SQLite、归一化遗留 key、清理无文件的孤儿记录 - -```bash -# 1. 预览迁移并生成 data/mapping.json(旧 key → 新 key) -docker exec pichost migrate - -# 2. 确认后执行文件移动 -docker exec pichost migrate --apply - -# 3. 升级镜像并重启;启动日志会打印索引同步过程 -``` - -本地:`npm run migrate` / `npm run migrate -- --apply`。 - -详细说明(结构对比、日志含义、外链替换、**其它图床迁入**):[`docs/migration-to-v1.2.md`](docs/migration-to-v1.2.md) - -仅对象存储、或图片已在 `data/images/` 下时,可跳过 CLI,仅依赖启动同步。 - -已克隆仓库时也可用 `docker compose up -d`(见仓库内 `docker-compose.yml`)。 - -### 本地开发 - -```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/` 目录。 - -## 存储结构 - -``` -/data -├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引 -└── images/ # 全部图片(本地后端时);可含迁移子路径如 file/、年/月/日/ -``` - -路径规则:新上传为 `images/随机ID.webp` 或 `images/年/月/随机ID.webp`;迁移遗留可为更深子路径(如 `images/blog/...`)。元数据在 SQLite `images` 表;云存储时文件在对应桶内。备份请包含 `data/images/`、`pichost.db` 与云桶数据。 - -从 v1.1.x 升级且存在 `data/blog/` 等并列目录时,见 [升级到 v1.2.0](#升级到-v120本地磁盘遗留目录) 或 [`docs/migration-to-v1.2.md`](docs/migration-to-v1.2.md)。 - -## API 概览 - -| 方法 | 路径 | 说明 | -| ------ | -------------------- | ---------------------- | -| 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 "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 # 单元测试 -npm run reset-password # 重置密码(可选用户名参数) -npm run migrate # 预览遗留目录迁移(加 -- --apply 执行) -``` - -## 路线图 - -| 版本 | 说明 | -| ---------- | ------------------------------------------------- | -| **v1.2.0** | 统一 `images/` 存储、遗留目录 CLI 迁移、启动索引同步、上传来源统计(开发中) | -| **v1.1.5** | 图库预览可靠性:自动重试、出图前校验、错误响应禁缓存 | -| **v1.1.4** | 存储路径扁平/分组、basename 外链、双域名双向隔离 | -| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示 | -| **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 | +| 分支 | 说明 | +| ---- | ---- | +| [**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) | ## License diff --git a/app/components/AdminLoginGate.vue b/app/components/AdminLoginGate.vue index b0d2e03..663a5d6 100644 --- a/app/components/AdminLoginGate.vue +++ b/app/components/AdminLoginGate.vue @@ -11,14 +11,29 @@ const username = ref('') const password = ref('') const secret = ref('') const loading = ref(false) +const captchaRef = ref<{ reset: () => Promise } | null>(null) +const captchaPayload = ref<{ captchaId: string, captchaPosition: number } | null>(null) const legacyMode = computed(() => authStatus.value?.legacyMode ?? false) const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false) 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 () => { const status = await fetchStatus() if (!status.initialized && !status.legacyMode) { @@ -27,13 +42,14 @@ onMounted(async () => { }) async function submit() { - if (!canSubmit.value || loading.value) return + if (!canSubmit.value || loading.value || !captchaPayload.value) return loading.value = true try { + const captcha = captchaPayload.value const result = legacyMode.value - ? await login({ secret: secret.value }) - : await login({ username: username.value.trim(), password: password.value }) + ? await login({ secret: secret.value }, captcha) + : await login({ username: username.value.trim(), password: password.value }, captcha) if (result.ok) { if (result.needsMigration) { @@ -43,8 +59,10 @@ async function submit() { username.value = '' password.value = '' secret.value = '' + await resetCaptcha() toast.add({ title: t('auth.loginSuccess'), color: 'success' }) } else { + await resetCaptcha() toast.add({ title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')), color: 'error' @@ -152,6 +170,11 @@ async function submit() { + + props.unit ?? t('common.itemsUnit')) +const jumpInput = ref('') 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 } 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 = '' +}) diff --git a/app/components/SliderCaptcha.vue b/app/components/SliderCaptcha.vue new file mode 100644 index 0000000..0e22285 --- /dev/null +++ b/app/components/SliderCaptcha.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index 04e60a7..81c7664 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -66,6 +66,11 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string { export type AuthActionResult = { ok: true } | { ok: false, error: string } +export interface LoginCaptchaPayload { + captchaId: string + captchaPosition: number +} + export function useAuth() { const { t } = useI18n() const status = useState('auth-status', () => 'checking') @@ -117,7 +122,8 @@ export function useAuth() { async function login( credentials: { username: string, password: string } - | { secret: string } + | { secret: string }, + captcha: LoginCaptchaPayload ): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> { try { const result = await $fetch<{ @@ -127,7 +133,11 @@ export function useAuth() { }>('/api/auth/login', { method: 'POST', credentials: 'include', - body: credentials + body: { + ...credentials, + captchaId: captcha.captchaId, + captchaPosition: captcha.captchaPosition + } }) if (result.needsMigration) { return { ok: true, needsMigration: true } diff --git a/app/composables/useUploadPreferences.ts b/app/composables/useUploadPreferences.ts index f61cca9..81668a9 100644 --- a/app/composables/useUploadPreferences.ts +++ b/app/composables/useUploadPreferences.ts @@ -65,6 +65,10 @@ export function useUploadPreferences() { loaded.value = true } + if (import.meta.client && !loaded.value) { + loadPreferences() + } + function savePreferences() { writeStored({ compressEnabled: compressEnabled.value, diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue index 344a441..a841b10 100644 --- a/app/pages/settings/index.vue +++ b/app/pages/settings/index.vue @@ -28,7 +28,13 @@ interface SettingsResponse { const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth() 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(null) const savingServer = ref(false) @@ -360,7 +366,7 @@ watch(isAuthenticated, async (authed, prev) => { > {{ t('setup.domainSeparationProxyHint') }} + 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 isMigrate = computed(() => needsMigration.value || route.query.migrate === '1') @@ -196,7 +202,7 @@ async function submit() {

{{ t('setup.domainSeparationProxyHint') }} 1) { + await goToPage(galleryPage.value - 1) + } } if (failed.length) { diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts new file mode 100644 index 0000000..0920183 --- /dev/null +++ b/docs-site/.vitepress/config.ts @@ -0,0 +1,129 @@ +import { defineConfig } from 'vitepress' + +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: '使用', + items: [ + { text: '存储', link: '/guide/storage' }, + { text: '用户与权限', link: '/guide/users-and-permissions' }, + { text: 'API', link: '/guide/api' }, + { text: 'Twikoo', link: '/guide/twikoo' } + ] + }, + { + text: '升级', + items: [ + { 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: '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: 'v1.2 migration', link: '/en/guide/migration' }, + { text: 'FAQ', link: '/en/guide/faq' } + ] + } +] + +export default defineConfig({ + base: '/PicHost/', + title: 'PicHost', + description: '自托管图床文档 · Self-hosted image hosting docs', + head: [['link', { rel: 'icon', href: '/PicHost/favicon.ico' }]], + 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 上编辑此页' + } + } +}) diff --git a/docs-site/.vitepress/theme/custom.css b/docs-site/.vitepress/theme/custom.css new file mode 100644 index 0000000..e5a92b7 --- /dev/null +++ b/docs-site/.vitepress/theme/custom.css @@ -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); +} diff --git a/docs-site/.vitepress/theme/index.ts b/docs-site/.vitepress/theme/index.ts new file mode 100644 index 0000000..42fe9a9 --- /dev/null +++ b/docs-site/.vitepress/theme/index.ts @@ -0,0 +1,4 @@ +import DefaultTheme from 'vitepress/theme' +import './custom.css' + +export default DefaultTheme diff --git a/docs-site/copy-screenshots.mjs b/docs-site/copy-screenshots.mjs new file mode 100644 index 0000000..6a0c93f --- /dev/null +++ b/docs-site/copy-screenshots.mjs @@ -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}`) diff --git a/docs-site/en/guide/api.md b/docs-site/en/guide/api.md new file mode 100644 index 0000000..921eee2 --- /dev/null +++ b/docs-site/en/guide/api.md @@ -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) diff --git a/docs-site/en/guide/configuration.md b/docs-site/en/guide/configuration.md new file mode 100644 index 0000000..f9824d7 --- /dev/null +++ b/docs-site/en/guide/configuration.md @@ -0,0 +1,101 @@ +# 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 → request origin | No | +| Hide `images/` prefix | 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: `; 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). Values must match proxy `server_name`; do not use `localhost`, IP, or unlisted hostnames for production admin access. + +### `HIDE_FOLDER_IN_URL` + +`true` hides the `images/` prefix in public URLs. + +### `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. diff --git a/docs-site/en/guide/domain-separation.md b/docs-site/en/guide/domain-separation.md new file mode 100644 index 0000000..a36aa26 --- /dev/null +++ b/docs-site/en/guide/domain-separation.md @@ -0,0 +1,167 @@ +# 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 +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://:6892` and forward `Host` and `X-Forwarded-Proto`. + +## Security notes + +### Middleware only splits the two configured hostnames + +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 + +If the Host is **neither** the configured site nor image hostname (e.g. `localhost`, the server **public IP**, Cloudflare **Pages** `*.pages.dev`, **Workers** `*.workers.dev`, or any other unlisted name), **the app does not block the request today** — admin and API may still be reachable. This is not about hyphenated or long domain names; it is an extra entry point outside your dual-domain pair. + +**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) +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** (extra `pages.dev` / `workers.dev` entry points worsen this issue) +5. 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) + +**Recommended:** both `admin.example.com` and `pic.example.com` **DNS Proxied** → origin Nginx / 1Panel → port `6892`, SSL **Full (strict)** (origin cert or Cloudflare Origin Certificate). + +| 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; Host stays your real domain | +| 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. + +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** (third Host — isolation skipped; OK for dev only; block 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) diff --git a/docs-site/en/guide/faq.md b/docs-site/en/guide/faq.md new file mode 100644 index 0000000..5e9083b --- /dev/null +++ b/docs-site/en/guide/faq.md @@ -0,0 +1,64 @@ +# FAQ + +## Forgot the admin password? + +With `docker exec` access: + +```bash +docker exec pichost reset-password +docker exec pichost reset-password +``` + +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? + +Dual-domain isolation only splits paths on the **configured site and image hostnames**. Other Host values (`localhost`, public IP, Cloudflare **Pages** `*.pages.dev`, **Workers** `*.workers.dev`, etc.) are **not blocked today**, so admin may appear on unexpected URLs. + +Common cause: **full-site reverse proxy** via Pages/Workers on top of orange-cloud DNS, or an origin that accepts any Host / bare IP. + +Fix: use only the two configured domains; add a default `server_name` block; with orange cloud, allow only CF IPs on the origin. See [Security notes](./domain-separation.md#security-notes). + +## 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: diff --git a/docs-site/en/guide/getting-started.md b/docs-site/en/guide/getting-started.md new file mode 100644 index 0000000..2195119 --- /dev/null +++ b/docs-site/en/guide/getting-started.md @@ -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://: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://: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 # admin or regular user +``` + +A random password is printed. Change it after login. + +Local dev: `npm run reset-password` or `npm run reset-password -- ` + +## 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) diff --git a/docs-site/en/guide/index.md b/docs-site/en/guide/index.md new file mode 100644 index 0000000..560d1b5 --- /dev/null +++ b/docs-site/en/guide/index.md @@ -0,0 +1,39 @@ +# 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. [Storage](./storage.md) — Backends and URL modes +4. [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. diff --git a/docs-site/en/guide/local-dev.md b/docs-site/en/guide/local-dev.md new file mode 100644 index 0000000..7fca011 --- /dev/null +++ b/docs-site/en/guide/local-dev.md @@ -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. diff --git a/docs/migration-to-v1.2.en.md b/docs-site/en/guide/migration.md similarity index 98% rename from docs/migration-to-v1.2.en.md rename to docs-site/en/guide/migration.md index 5eb4c04..ff640f3 100644 --- a/docs/migration-to-v1.2.en.md +++ b/docs-site/en/guide/migration.md @@ -126,4 +126,4 @@ Skip step 1 if you only use object storage or everything is already under `data/ With “hide folder” mode, short URLs like `/TeKJ2fD6cB6j.webp` may still resolve by filename. Use `mapping.json` for bulk replacements. -Chinese version: [migration-to-v1.2.md](migration-to-v1.2.md) +Chinese version: [v1.2 迁移指南](/guide/migration) diff --git a/docs-site/en/guide/reverse-proxy.md b/docs-site/en/guide/reverse-proxy.md new file mode 100644 index 0000000..4576ced --- /dev/null +++ b/docs-site/en/guide/reverse-proxy.md @@ -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 + +Run PicHost on **your server / Docker** (port `6892`). On Cloudflare: + +| Practice | Notes | +| -------- | ----- | +| **Proxied DNS** | Both admin and image hostnames → origin proxy to `6892`, SSL **Full (strict)** | +| Preferred edge IPs | Compatible; Host remains your real domain | +| **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** to PicHost — extra hostnames can bypass dual-domain isolation ([Security notes](./domain-separation.md#security-notes)) +- “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) diff --git a/docs-site/en/guide/storage.md b/docs-site/en/guide/storage.md new file mode 100644 index 0000000..487256b --- /dev/null +++ b/docs-site/en/guide/storage.md @@ -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://.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) diff --git a/docs-site/en/guide/twikoo.md b/docs-site/en/guide/twikoo.md new file mode 100644 index 0000000..fe286c3 --- /dev/null +++ b/docs-site/en/guide/twikoo.md @@ -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) diff --git a/docs-site/en/guide/users-and-permissions.md b/docs-site/en/guide/users-and-permissions.md new file mode 100644 index 0000000..6d05741 --- /dev/null +++ b/docs-site/en/guide/users-and-permissions.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) diff --git a/docs-site/en/index.md b/docs-site/en/index.md new file mode 100644 index 0000000..9d61d59 --- /dev/null +++ b/docs-site/en/index.md @@ -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." +--- diff --git a/docs-site/guide/api.md b/docs-site/guide/api.md new file mode 100644 index 0000000..f006459 --- /dev/null +++ b/docs-site/guide/api.md @@ -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 diff --git a/docs-site/guide/configuration.md b/docs-site/guide/configuration.md new file mode 100644 index 0000000..0a25623 --- /dev/null +++ b/docs-site/guide/configuration.md @@ -0,0 +1,101 @@ +# 环境变量 + +PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。 + +## 优先级总览 + +| 配置项 | 优先级(高 → 低) | 环境变量覆盖后台 | +| ------ | ----------------- | ---------------- | +| API 上传 Token | 环境变量 → SQLite | **是**(设了则后台无法重新生成) | +| WebP 质量 | SQLite → 环境变量 → 默认 80 | 否(未在 DB 存时读 env) | +| 防盗链白名单 | SQLite → 环境变量 | 否 | +| 网站 / 图片域名 | SQLite → 环境变量 → 当前请求 | 否 | +| 隐藏 `images/` 前缀 | SQLite → 环境变量 → 默认 false | 否 | +| 按年/月分组存储 | SQLite → 环境变量 → 默认 true | 否 | +| 全局自动删除天数 | SQLite → 环境变量 → 默认 0 | 否 | + +## 变量说明 + +### `API_UPLOAD_TOKEN` + +外部脚本上传 Token(Twikoo / 油猴 / 博客)。请求头:`Auth-Token: `;表单上传仍可用 `token` 字段。 + +- 未配置时可在后台 **API** 页生成 +- **环境变量一旦设置,优先级最高**,后台显示「由环境变量覆盖」且无法重新生成 +- 别名:`NUXT_API_UPLOAD_TOKEN` + +### `ALLOWED_REFERER_HOSTS` + +防盗链白名单,逗号分隔域名。自身域名自动放行。可选;不填可在设置页配置。若环境变量有值且 SQLite 未存,则使用环境变量。 + +### `SITE_BASE_URL` / `IMAGE_BASE_URL` + +- **网站域名**:管理后台、API、Twikoo 上传入口 +- **图片域名**:复制链接与直链使用的公网地址 + +双域名分离需两者主机名不同,详见 [双域名分离](./domain-separation.md)。填写后须与反代 `server_name` 一致;**勿**通过 `localhost`、IP 或未配置的域名访问生产后台。 + +### `HIDE_FOLDER_IN_URL` + +`true` 时直链隐藏 `images/` 前缀(如 `images/2026/08/a.webp` → `/2026/08/a.webp` 或仅文件名,取决于路径模式)。 + +### `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)。 diff --git a/docs/domain-separation.md b/docs-site/guide/domain-separation.md similarity index 56% rename from docs/domain-separation.md rename to docs-site/guide/domain-separation.md index 2180de1..bf0f37f 100644 --- a/docs/domain-separation.md +++ b/docs-site/guide/domain-separation.md @@ -42,10 +42,52 @@ server { 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` 头。 +## 安全与注意事项 + +### 中间件只隔离「已配置的两个域名」 + +PicHost 根据请求 **Host**(反代会转发为 `X-Forwarded-Host`,应用会一并识别)判断当前是网站域还是图片域: + +- **图片域**:非图片路径(如 `/`、`/settings`)→ 404 +- **网站域**:图片直链路径(如 `/images/...`)→ 404 + +若 Host **既不是网站域也不是图片域**(例如 `localhost`、服务器 **公网 IP**、Cloudflare **Pages** 的 `*.pages.dev`、**Workers** 的 `*.workers.dev`、或其它未在设置里填写的域名),**当前版本不会拦截**,后台与 API 仍可能被访问。这与域名是否带 `-`、长短无关,而是多了一条未纳入双域名配置的入口。 + +**生产环境建议:** + +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` 等第三入口,且易出现上述绕过) +5. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可 + +### Cloudflare 橙云(含 DNS 优选) + +**推荐架构:** `admin.example.com` 与 `pic.example.com` 均 **DNS Proxied(橙云)** → 源站 Nginx / 1Panel 反代 `6892`,SSL 模式 **Full (strict)**(源站可用 Cloudflare Origin Certificate)。 + +| 能力 | 说明 | +| ---- | ---- | +| 橙云 CDN | 与 PicHost 双域名兼容;回源须保留 `Host`、`X-Forwarded-Proto` | +| DNS 优选 | 仅改变用户连到 CF 边缘的 IP,Host 仍为正式域名,无额外配置 | +| R2 对象存储 | 在 **存储** 页添加 R2 后端即可,与是否橙云无关 | +| `cloudflare` Git 分支 | R2 专用部署线,仍是 Docker 运行,**不是** Workers 托管 | + +**勿混淆:** CF 控制台「创建 Worker」并连接本仓库 **无法** 直接部署 PicHost;`npm run build` + `wrangler deploy` 与当前 `node-server` 预设不兼容。 + +反代与源站加固详见 [反向代理](./reverse-proxy.md#cloudflare-橙云)。 + ## 飞牛 NAS · Lucky 反向代理 在 [Lucky](https://github.com/gdy666/lucky) 中为两个域名各建一条 **反向代理** 规则,**前端地址** 填公网域名,**后端地址** 填 PicHost 所在内网 IP 与端口(如 `http://192.168.8.3:6892`)。 @@ -58,8 +100,6 @@ server { | 前端地址 | `admin.pichost.com` | | 后端地址 | `http://192.168.8.3:6892` | -![Lucky 网站域名配置](screenshots/lucky-site.png) - **图片域名** | 项 | 示例值 | @@ -68,8 +108,6 @@ server { | 前端地址 | `image.pichost.com` | | 后端地址 | `http://192.168.8.3:6892` | -![Lucky 图片域名配置](screenshots/lucky-image.png) - 两条规则的后端地址相同即可;域名分离与路径隔离由 PicHost 根据请求 `Host` 自动处理。 ## 环境变量(可选) @@ -81,6 +119,8 @@ SITE_BASE_URL=https://admin.example.com IMAGE_BASE_URL=https://pic.example.com ``` +详见 [环境变量](./configuration.md)。 + ## 本地测试(Windows / macOS) ### 1. 修改 hosts @@ -117,13 +157,16 @@ npm run dev | `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://localhost:3000/` 或 `http://127.0.0.1:3000/` | **能打开后台**(第三 Host,隔离不生效;仅开发可接受,生产须用反代封禁) | 上传一张图后,复制链接应指向 `pic.pichost.test` 域名;在后台图库中缩略图也应能正常显示。 ## 相关说明 - 两个域名 **主机名不能相同**(可同机、不同子域) +- 设置里的网站域 / 图片域须与反代 `server_name` **完全一致**(含是否带 `www`) - 图片域无需单独部署静态目录;与 EasyImages 等不同,PicHost 在应用内完成隔离 - 单域名部署可不启用分离,仅配置 `IMAGE_BASE_URL` 或使用默认当前访问域名 - 配置了 Referer 防盗链时,网站域与图片域会自动加入白名单,无需手写两个域名 + +单域名反代要点见 [反向代理](./reverse-proxy.md)。英文版:[Dual-domain separation](/en/guide/domain-separation) diff --git a/docs-site/guide/faq.md b/docs-site/guide/faq.md new file mode 100644 index 0000000..1a29ff6 --- /dev/null +++ b/docs-site/guide/faq.md @@ -0,0 +1,65 @@ +# 常见问题 + +## 忘记管理员密码怎么办? + +能执行 `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 也能进后台? + +PicHost 双域名隔离**只对已配置的网站域、图片域做路径分流**;其它 Host(`localhost`、公网 IP、Cloudflare Pages 的 `*.pages.dev`、Workers 的 `*.workers.dev` 等)**当前不会拦截**,因此可能出现「奇怪地址也能开后台」。 + +常见原因:在橙云之外又用 **Pages/Workers 整站反代** 到源站,或源站未限制 `server_name` / 允许裸 IP 访问。 + +处理:仅用正式两个域名访问;反代加 default server;橙云时源站只放行 CF IP。详见 [双域名分离 · 安全与注意事项](./domain-separation.md#安全与注意事项)。 + +## 能把 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 保留摘要与快速开始。在线地址: diff --git a/docs-site/guide/getting-started.md b/docs-site/guide/getting-started.md new file mode 100644 index 0000000..a3216d7 --- /dev/null +++ b/docs-site/guide/getting-started.md @@ -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 接口 diff --git a/docs-site/guide/index.md b/docs-site/guide/index.md new file mode 100644 index 0000000..87b3b3f --- /dev/null +++ b/docs-site/guide/index.md @@ -0,0 +1,39 @@ +# PicHost 文档 + +欢迎使用 PicHost 用户文档。本站为完整指南的单一事实来源;仓库 README 保留特性摘要与快速开始。 + +## 推荐阅读顺序 + +1. [快速开始](./getting-started.md) — Docker 部署与首次设置 +2. [环境变量](./configuration.md) — 生产环境配置与后台设置对照 +3. [存储](./storage.md) — 多后端与直链模式 +4. [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** 分支。 diff --git a/docs-site/guide/local-dev.md b/docs-site/guide/local-dev.md new file mode 100644 index 0000000..dcec8fa --- /dev/null +++ b/docs-site/guide/local-dev.md @@ -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/`。 diff --git a/docs/migration-to-v1.2.md b/docs-site/guide/migration.md similarity index 99% rename from docs/migration-to-v1.2.md rename to docs-site/guide/migration.md index 4191284..5583982 100644 --- a/docs/migration-to-v1.2.md +++ b/docs-site/guide/migration.md @@ -175,4 +175,4 @@ CLI 只负责**搬文件**;索引对齐、遗留 key、孤儿记录由启动 | `docker exec pichost migrate --apply` | 执行磁盘迁移 | | `docker exec pichost reset-password` | 重置密码(与迁移无关) | -英文版:[migration-to-v1.2.en.md](migration-to-v1.2.en.md) +英文版:[v1.2 migration guide](/en/guide/migration) diff --git a/docs-site/guide/reverse-proxy.md b/docs-site/guide/reverse-proxy.md new file mode 100644 index 0000000..ed05ec0 --- /dev/null +++ b/docs-site/guide/reverse-proxy.md @@ -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 橙云 + +PicHost 应用运行在 **自有服务器 / Docker**(端口 `6892`),Cloudflare 侧推荐: + +| 做法 | 说明 | +| ---- | ---- | +| **DNS 橙云** | `admin` / `pic` 两个子域 Proxied → 源站反代 `6892`,SSL **Full (strict)** | +| **DNS 优选** | 与 PicHost 兼容;Host 仍为正式域名 | +| **R2** | 在存储页作对象后端,非 Pages 托管应用 | +| **源站防火墙** | 仅放行 [Cloudflare IP](https://www.cloudflare.com/ips/),禁止公网直连 `6892` | + +**不建议:** + +- 用 **Cloudflare Pages**(`*.pages.dev`)或 **Workers**(`*.workers.dev`)对 PicHost **整站反代** — 会多出第三 Host,且可能绕过双域名隔离(见 [双域名分离 · 安全与注意事项](./domain-separation.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 端口 diff --git a/docs-site/guide/storage.md b/docs-site/guide/storage.md new file mode 100644 index 0000000..543f1fa --- /dev/null +++ b/docs-site/guide/storage.md @@ -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://.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) — 谁可以管理存储 diff --git a/docs-site/guide/twikoo.md b/docs-site/guide/twikoo.md new file mode 100644 index 0000000..97b2489 --- /dev/null +++ b/docs-site/guide/twikoo.md @@ -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) diff --git a/docs-site/guide/users-and-permissions.md b/docs-site/guide/users-and-permissions.md new file mode 100644 index 0000000..3682ef9 --- /dev/null +++ b/docs-site/guide/users-and-permissions.md @@ -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) diff --git a/docs-site/index.md b/docs-site/index.md new file mode 100644 index 0000000..048e7a6 --- /dev/null +++ b/docs-site/index.md @@ -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 协议,评论系统一键接入。" +--- diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 9db26f1..0000000 --- a/docs/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# PicHost 文档资源 - -本目录主要存放 README 引用的界面截图与分支说明。 - -## 分支 - -| 分支 | 说明 | -| ---- | ---- | -| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.2.0**(开发中)主线:统一 `images/` 存储、多对象存储、[`/storage`](docs/README.md)、[`迁移指南`](migration-to-v1.2.md) | -| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署场景,配置更聚焦 R2 | - -- 需要多种云存储或完整管理界面 → 使用 **main** -- 图床只接 **Cloudflare R2** → 可选用 **`cloudflare`** 分支 - -## 截图一览 - -以下为 **main** 界面截图。 - -| 文件 | 说明 | -| ---- | ---- | -| `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:图片域名反代示例 | - -## 部署文档 - -| 文档 | 说明 | -| ---- | ---- | -| [migration-to-v1.2.md](migration-to-v1.2.md) | v1.2.0 本地磁盘迁移(CLI 搬文件 + 启动同步索引) | -| [migration-to-v1.2.en.md](migration-to-v1.2.en.md) | v1.2.0 migration guide (English) | -| [domain-separation.md](domain-separation.md) | 双域名分离推荐架构(Nginx / Lucky / 环境变量) | -| [domain-separation.en.md](domain-separation.en.md) | Dual-domain deployment (English) | - -## 相关页面路由(main) - -| 路由 | 权限 | 说明 | -| ---- | ---- | ---- | -| `/` | 登录用户 | 上传与图库入口 | -| `/storage` | 管理员 | 存储后端管理 | -| `/settings` | 管理员 | 系统设置 | -| `/stats` | 登录用户 | 统计 | -| `/api` | 登录用户 | API 文档与 Token | - -管理员顶栏:**API** · **统计** · **存储** · **设置**。用户下拉菜单仅 **修改密码**、**退出登录**。 - -## Cloudflare R2 配置对照 - -**main**:后台 **存储 → 添加存储 → Cloudflare R2** 预设。 - -**cloudflare 分支**(R2 专用):通常以环境变量为主: - -| 配置项 | 值 | -| ------ | -- | -| `STORAGE_BACKEND` | `s3` | -| `S3_ENDPOINT` | `https://.r2.cloudflarestorage.com` | -| `S3_REGION` | `auto` | -| `S3_BUCKET` | 桶名 | -| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | R2 API Token | -| 直链 | 默认 PicHost 代理;桶绑域名后可改公开 URL | diff --git a/docs/domain-separation.en.md b/docs/domain-separation.en.md deleted file mode 100644 index a1131d1..0000000 --- a/docs/domain-separation.en.md +++ /dev/null @@ -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://: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 diff --git a/docs/screenshots/api.png b/docs/screenshots/api.png new file mode 100644 index 0000000..39a33d5 Binary files /dev/null and b/docs/screenshots/api.png differ diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png index 85e13ab..db640de 100644 Binary files a/docs/screenshots/login.png and b/docs/screenshots/login.png differ diff --git a/docs/screenshots/logs.png b/docs/screenshots/logs.png new file mode 100644 index 0000000..75853b7 Binary files /dev/null and b/docs/screenshots/logs.png differ diff --git a/docs/screenshots/lucky-image.png b/docs/screenshots/lucky-image.png deleted file mode 100644 index 4797ab5..0000000 Binary files a/docs/screenshots/lucky-image.png and /dev/null differ diff --git a/docs/screenshots/lucky-site.png b/docs/screenshots/lucky-site.png deleted file mode 100644 index df5e960..0000000 Binary files a/docs/screenshots/lucky-site.png and /dev/null differ diff --git a/docs/screenshots/settings-en.png b/docs/screenshots/settings-en.png new file mode 100644 index 0000000..799f10e Binary files /dev/null and b/docs/screenshots/settings-en.png differ diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png index 9564598..da75575 100644 Binary files a/docs/screenshots/settings.png and b/docs/screenshots/settings.png differ diff --git a/docs/screenshots/setup.png b/docs/screenshots/setup.png new file mode 100644 index 0000000..a3937a9 Binary files /dev/null and b/docs/screenshots/setup.png differ diff --git a/docs/screenshots/stats.png b/docs/screenshots/stats.png index 6d79ebf..49d40be 100644 Binary files a/docs/screenshots/stats.png and b/docs/screenshots/stats.png differ diff --git a/docs/screenshots/storage.png b/docs/screenshots/storage.png index e73b193..9e47fbd 100644 Binary files a/docs/screenshots/storage.png and b/docs/screenshots/storage.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs index 934c3a1..f21e65d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,5 +2,8 @@ import withNuxt from './.nuxt/eslint.config.mjs' export default withNuxt( + { + ignores: ['docs-site/**'] + } // Your custom configs here ) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index c3c485a..9c99a0c 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -64,6 +64,8 @@ "loginFailedSecret": "Sign-in failed. Check your secret", "loginFailedCredentials": "Invalid username or password", "loginFailed": "Sign-in failed. Check username and password", + "captchaHint": "Drag the slider to the slot", + "captchaSuccess": "Verified", "registerFailed": "Registration failed. Check your input", "setupFailed": "Setup failed. Check your input", "migrateFailed": "Migration failed. Check your input", @@ -376,7 +378,9 @@ "first": "First page", "prev": "Previous page", "next": "Next page", - "last": "Last page" + "last": "Last page", + "goTo": "Go", + "pagePlaceholder": "Page" }, "api": { "tokenTitle": "API Token", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index dbfc839..71be5bf 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -64,6 +64,8 @@ "loginFailedSecret": "登录失败,请检查密钥", "loginFailedCredentials": "用户名或密码错误", "loginFailed": "登录失败,请检查用户名和密码", + "captchaHint": "拖动滑块对齐缺口", + "captchaSuccess": "验证通过", "registerFailed": "注册失败,请检查输入", "setupFailed": "初始化失败,请检查输入", "migrateFailed": "迁移失败,请检查输入", @@ -376,7 +378,9 @@ "first": "第一页", "prev": "上一页", "next": "下一页", - "last": "最后一页" + "last": "最后一页", + "goTo": "跳转", + "pagePlaceholder": "页码" }, "api": { "tokenTitle": "API Token", diff --git a/package-lock.json b/package-lock.json index 89dae44..3dcf5b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pichost", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pichost", - "version": "1.2.0", + "version": "1.2.1", "hasInstallScript": true, "license": "GPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 4f6797a..0c993bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pichost", - "version": "1.2.0", + "version": "1.2.1", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "homepage": "https://github.com/O96u/PicHost#readme", "repository": { @@ -34,7 +34,11 @@ "test:watch": "vitest", "start": "node .output/server/index.mjs", "reset-password": "node server/cli/reset-password.mjs", - "migrate": "node server/cli/migrate-to-single-images.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": { "@aws-sdk/client-s3": "^3.876.0", @@ -50,6 +54,7 @@ "@types/node": "^22.15.0", "eslint": "^10.6.0", "typescript": "^6.0.3", + "vitepress": "^1.6.4", "vitest": "^3.2.4", "vue-tsc": "^3.3.5" } diff --git a/server/api/auth/captcha.get.ts b/server/api/auth/captcha.get.ts new file mode 100644 index 0000000..c9123b4 --- /dev/null +++ b/server/api/auth/captcha.get.ts @@ -0,0 +1,6 @@ +import { createLoginCaptchaChallenge } from '../../utils/login-captcha' + +export default defineEventHandler(() => { + const { id, svg } = createLoginCaptchaChallenge() + return { id, svg } +}) diff --git a/server/api/auth/captcha/confirm.post.ts b/server/api/auth/captcha/confirm.post.ts new file mode 100644 index 0000000..aa0fb6f --- /dev/null +++ b/server/api/auth/captcha/confirm.post.ts @@ -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(event) + const id = body?.id?.trim() ?? '' + const position = Number(body?.captchaPosition) + + if (!id || !confirmLoginCaptcha(id, position)) { + createApiError(event, 'INVALID_REQUEST', '滑块未对齐,请重试', 400) + } + + return { success: true } +}) diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 0a9f16d..a47ae10 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -1,3 +1,4 @@ +import type { H3Event } from 'h3' import { checkAdminSecret, setLegacyAuthCookie @@ -10,12 +11,15 @@ import { } from '../../utils/auth' import { findUserByUsername } from '../../utils/db' import { getAdminSecret } from '../../utils/env' +import { verifyLoginCaptcha } from '../../utils/login-captcha' import { clientIp, logInfo, logWarn } from '../../utils/logger' interface LoginBody { username?: string password?: string secret?: string + captchaId?: string + captchaPosition?: number } const loginAttempts = new Map() @@ -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) => { const ip = clientIp(event) checkRateLimit(ip) const body = await readBody(event) + verifyCaptchaOrFail(event, body ?? {}) const initialized = isInitialized() if (!initialized) { diff --git a/server/utils/login-captcha.test.ts b/server/utils/login-captcha.test.ts new file mode 100644 index 0000000..49ac27d --- /dev/null +++ b/server/utils/login-captcha.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { + __peekLoginCaptchaTargetForTests, + __resetLoginCaptchaStoreForTests, + confirmLoginCaptcha, + createLoginCaptchaChallenge, + verifyLoginCaptcha +} from './login-captcha' + +describe('login-captcha', () => { + it('requires confirm before login verify', () => { + __resetLoginCaptchaStoreForTests() + const { id } = createLoginCaptchaChallenge() + const target = __peekLoginCaptchaTargetForTests(id)! + expect(verifyLoginCaptcha(id, target)).toBe(false) + expect(confirmLoginCaptcha(id, target)).toBe(true) + expect(verifyLoginCaptcha(id, target)).toBe(true) + expect(verifyLoginCaptcha(id, target)).toBe(false) + }) + + it('rejects position outside tolerance', () => { + __resetLoginCaptchaStoreForTests() + const { id } = createLoginCaptchaChallenge() + const target = __peekLoginCaptchaTargetForTests(id)! + expect(confirmLoginCaptcha(id, target + 10)).toBe(false) + }) +}) diff --git a/server/utils/login-captcha.ts b/server/utils/login-captcha.ts new file mode 100644 index 0000000..bf00859 --- /dev/null +++ b/server/utils/login-captcha.ts @@ -0,0 +1,85 @@ +const TOLERANCE_PERCENT = 4 +const TTL_MS = 5 * 60 * 1000 +const TRACK_WIDTH = 320 +const TRACK_HEIGHT = 48 +const TRACK_INSET = 4 +const SLOT_SIZE = 40 + +interface CaptchaChallenge { + targetPercent: number + expiresAt: number + confirmed: boolean +} + +const challenges = new Map() + +function purgeExpired() { + const now = Date.now() + for (const [id, challenge] of challenges) { + if (challenge.expiresAt <= now) { + challenges.delete(id) + } + } +} + +function isPositionMatch(targetPercent: number, positionPercent: number): boolean { + return Math.abs(positionPercent - targetPercent) <= TOLERANCE_PERCENT +} + +/** 缺口槽位 SVG(不返回 targetPercent 数字) */ +export function buildCaptchaTrackSvg(targetPercent: number): string { + const maxTravel = TRACK_WIDTH - SLOT_SIZE - TRACK_INSET * 2 + const slotX = (TRACK_INSET + (targetPercent / 100) * maxTravel).toFixed(1) + const slotY = (TRACK_HEIGHT - SLOT_SIZE) / 2 + return `` +} + +export function createLoginCaptchaChallenge(): { id: string, svg: string } { + purgeExpired() + const id = crypto.randomUUID() + const targetPercent = Math.floor(20 + Math.random() * 61) + challenges.set(id, { + targetPercent, + expiresAt: Date.now() + TTL_MS, + confirmed: false + }) + return { id, svg: buildCaptchaTrackSvg(targetPercent) } +} + +export function confirmLoginCaptcha(id: string, positionPercent: number): boolean { + purgeExpired() + const challenge = challenges.get(id) + if (!challenge || challenge.expiresAt <= Date.now()) { + if (challenge) challenges.delete(id) + return false + } + if (!Number.isFinite(positionPercent)) return false + if (!isPositionMatch(challenge.targetPercent, positionPercent)) { + return false + } + challenge.confirmed = true + return true +} + +export function verifyLoginCaptcha(id: string, positionPercent: number): boolean { + purgeExpired() + const challenge = challenges.get(id) + if (!challenge || challenge.expiresAt <= Date.now()) { + if (challenge) challenges.delete(id) + return false + } + if (!challenge.confirmed) return false + challenges.delete(id) + if (!Number.isFinite(positionPercent)) return false + return isPositionMatch(challenge.targetPercent, positionPercent) +} + +export function __resetLoginCaptchaStoreForTests() { + challenges.clear() +} + +export function __peekLoginCaptchaTargetForTests(id: string): number | undefined { + return challenges.get(id)?.targetPercent +}