Author SHA1 Message Date
tianmo 51d3acfbb5 用户管理与数据操作 2026-10-27 11:03:36 +08:00
tianmo ed0f20b754 mac启动停止脚本 2026-10-19 19:02:23 +08:00
tianmo ccfdac2e1b feat: 支持注销用户并展示普通用户使用情况
- 新增管理员注销用户接口,要求输入用户名二次确认
- 注销时清理用户当前图片、视频、文件、标签、会话和自身日志
- 保留备份任务、管理员审计数据和全局配置
- 用户列表展示图片、视频、文件占用,管理员行隐藏使用情况和操作按钮
- 补充注销流程与用量统计测试
2026-10-19 17:48:44 +08:00
tianmo dd0005b7ba 清除密码 2026-10-19 16:27:48 +08:00
tianmo 366b8a4272 fix: 多选同步弹窗复用站点同步表单缓存
- 抽取站点地址与 Token 的 24 小时缓存工具
- 存储页与多选同步弹窗共用 pichost.site-sync-form
- 弹窗打开时读取最新缓存,修改后写回
- 增加缓存读取与过期测试
2026-10-19 16:10:48 +08:00
tianmo 58424fe55d fix: 支持备份恢复与站点同步的大文件上传 版本更新到1.5.4
- 备份恢复的 multipart 分支改用 Web FormData,移除旧解析器约 128 MB 的限制
- 初始化恢复新增 application/octet-stream 流式写盘,前端改为直接上传文件流,避免整包进入内存
- 站点同步改用 Web FormData,并在读取请求体前拒绝超过 500 MB 的文件
- 新增 multipart 字段、重复字段和文件读取回归测试

验证:
- 2 GB 测试备份包流式恢复预览成功,服务内存保持低位
- 130 MB 站点同步上传成功
- 原有备份预览及图片、视频、文件上传正常
- npm test 234 项通过;npm run typecheck 与 eslint 通过
2026-10-19 15:26:13 +08:00
tianmo 3759422222 超大文件上传限制 2026-10-19 15:04:41 +08:00
Yeomai 885534378b 自述修改 2026-10-18 21:35:59 +08:00
43 changed files with 1547 additions and 283 deletions

No files matched your search

+152 -41
View File
@@ -4,32 +4,37 @@
<h1 align="center">PicHost</h1>
<p align="center"><strong>Lightweight personal image hosting</strong> · Simple uploads, clean management</p>
<p align="center"><strong>Self-hosted image, video, and file management platform</strong></p>
<p align="center">Self-hosted / Multi-user / Docker / API / Twikoo · Local disk or object storage</p>
<p align="center">Multi-user · WebP compression · HLS video · File hosting · Tags · Multi-backend storage · Full backup and migration</p>
<p align="center">
<a href="https://github.com/O96u/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/yeomaiyelove-cpu/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/yeomaiyelove-cpu/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<a href="https://hub.docker.com/r/yeomaiyelove/pichost"><img src="https://img.shields.io/badge/Docker-yeomaiyelove%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/Windows-x64-0078D4?style=flat-square&logo=windows&logoColor=white" alt="Windows x64" />
<img src="https://img.shields.io/badge/FnOS-x86-00A67E?style=flat-square" alt="FnOS x86" />
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p>
<p align="center">
<a href="https://o96u.github.io/PicHost/en/">Docs</a> ·
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#tech-stack">Tech Stack</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#deployment">Deployment</a> ·
<a href="#development-and-packaging">Development</a> ·
<a href="docs-site/en/">Documentation source</a> ·
<a href="README.md">简体中文</a>
</p>
---
## Screenshots
## Overview
**main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc)
PicHost is a media management platform for NAS devices, home servers, and small self-hosted deployments. It manages images, videos, and general-purpose files in one place, with multi-user access, tags, search, multiple storage backends, HLS video processing, full-site backup and restore, backend migration, and site-to-site synchronization.
The first visit opens a web setup wizard. Administrators can either create an account or restore an existing `.phost.tar.gz` backup.
## Screenshots
| Upload | Gallery |
| :----: | :-----: |
@@ -53,29 +58,60 @@
## Features
- **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
- **Multi-user** — login with slider, Turnstile, or Cap verification; optional registration; users see only their images
- **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS) + Telegram channel/group; hybrid `proxy` / `public` URLs
- **Gallery** — browse, search, filter by storage/source/tags, grid/list views, batch delete and batch tagging; stats overview and source breakdown; image detail modal with link formats
- **Tags** — per-user colored tags; tag on upload (single or batch); manage in Settings (create, recolor, merge, delete); API supports `tagIds` and batch tagging
- **Backup & migration** — full-site export/restore (`.phost.tar.gz`), cross-backend sync; restore from backup on uninitialized `/setup`
- **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
- **Zero-config Docker** — first-run web wizard, no secrets upfront
### Images
## Tech Stack
- Upload by drag and drop, file picker, or `Ctrl+V` paste, with configurable formats, size limits, batch limits, and rate limits.
- Server-side Sharp-based WebP compression, with optional original preservation and optional browser-side WebP compression.
- Search, pagination, storage/source/tag filters, grid or list layouts, detail views, multiple link formats, bulk tagging, and bulk deletion.
- Media counts and source statistics, Referer hotlink protection, and configurable referrer allowlists.
| Layer | Technologies |
| ----- | ------------ |
| Frontend | [Nuxt 4](https://nuxt.com) · [Nuxt UI 4](https://ui.nuxt.com) · [Vue 3](https://vuejs.org) · [Tailwind CSS 4](https://tailwindcss.com) · TypeScript |
| Backend | [Nitro](https://nitro.build) (`node-server`) · REST API |
| Data | SQLite · local `data/pichost.db` |
| Images | [sharp](https://sharp.pixelplumbing.com) (server-side WebP) |
| Storage | Local disk · S3-compatible ([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS, etc.) · Telegram channel/group |
| i18n | [@nuxtjs/i18n](https://i18n.nuxtjs.org) (zh-CN / English) |
| Quality & docs | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) docs site |
| Deploy | Docker · Node.js 22 |
### Video and HLS
## Quick Start
- Dedicated video upload and library pages for MP4, M4V, WebM, MOV, MKV, AVI, FLV, WMV, MPG, and MPEG.
- Automatic thumbnail generation and optional asynchronous HLS transcoding.
- `copy`, `veryfast`, `medium`, `slow`, and `compress` presets, with configurable concurrency, CPU thread count, and target bitrate.
- Transcode progress and failure reporting, with automatic cleanup of the source, thumbnail, playlist, and HLS segments.
- Original, thumbnail, and HLS links, plus MP4 remux download for HLS videos.
### File Hosting
- A dedicated file library with uploads, search, pagination, tags, bulk deletion, direct links, and download links.
- Configurable file-bed access policy, with separate domains supported for images, videos, and files.
### Users and Access Control
- Administrator and standard-user accounts; standard users only see and manage their own media.
- Slider, Cloudflare Turnstile, and Cap verification, with optional public registration.
- Profile and password management, administrator password reset, and account-system migration.
- Per-user tags and automatic deletion policies.
- Login and upload rate limits, global and personal API tokens, and activity logs.
### Storage
- Local disk, S3-compatible object storage, and Telegram channels or groups.
- S3-compatible support includes R2, COS, OSS, AWS S3, and similar services, with `proxy` or `public` access modes.
- Multiple backends, configurable default and enabled states, and per-backend capacity quotas.
- Migration of images, videos, files, and indexes between configured backends, with an optional target backend switch after migration.
### Backup and Synchronization
- Complete `.phost.tar.gz` site backups containing the database, images, videos, files, thumbnails, HLS content, and tags.
- Restore previews for file count, media size, new files, conflicts, and skip/overwrite conflict handling.
- Streaming uploads for large backups without loading the complete archive into memory.
- Restore directly from the first-run setup page; cloud storage secrets are excluded and must be re-entered after restore.
- Push or pull synchronization between two PicHost sites, for all media or only images, videos, or files.
- Resume support, conflict policies, and job progress; matching usernames must already exist on both sites.
### API and Compatibility
- APIs for images, videos, files, tags, search, bulk operations, backups, and system information.
- Global and personal tokens, accepted through request headers or upload forms.
- Twikoo / EasyImage-compatible uploads through `POST /api/index.php`.
- Built-in API documentation and debugger with generated `curl` examples.
## Deployment
### Docker
```bash
docker run -d \
@@ -83,22 +119,97 @@ docker run -d \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
yeomaiyelove/pichost:latest
```
Open `http://<host>:6892` and complete the setup wizard.
Open `http://<host>:6892` and complete the setup wizard. Data is stored in the host `./data` directory by default.
**Full guide** (env vars, upgrades, dual-domain setup, API, Twikoo, reverse proxy, local dev, etc.): **[Documentation](https://o96u.github.io/PicHost/en/)**.
Docker Hub:
| 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) |
- `yeomaiyelove/pichost:latest`
- `yeomaiyelove/pichost:<version>`
### Windows
The Windows x64 installer is self-contained:
- Node.js and FFmpeg are bundled; no runtime download is required on the target computer.
- The first launch opens the web setup wizard.
- Data is stored in the `data` directory under the installation directory.
- Uninstall asks whether to delete the data directory. If preserved, the database, media, and backups remain on disk.
### FnOS
FnOS uses a dedicated x86 FPK package that runs the bundled Docker image:
1. Import the matching `yeomaiyelove/pichost:<version>` image into FnOS Docker.
2. Confirm that the image appears locally.
3. Install the FPK and choose account creation or backup restore in the wizard.
The FPK uses the local image and does not pull PicHost from the internet.
## Development and Packaging
### Local Development
```bash
npm ci
npm run dev
```
The first visit opens `/setup`. Common verification commands:
```bash
npm test
npm run typecheck
npm run lint
npm run build
```
### Release Artifacts
```bash
npm run pack:docker
npm run pack:fpk
npm run pack:windows
```
On Windows, all artifacts can be built at once:
```bash
npm run pack:all
```
Output directories:
- Docker image archive: `dist-docker/`
- FnOS package: `dist-fpk/`
- Windows installer: `dist-windows/release/`
See [packaging/README.md](packaging/README.md) for requirements and override options.
## Data Directory
- Docker uses `/data` by default and supports the `DATA_DIR` override.
- Windows installs use the `data` directory under the installation path.
- Local development uses `data` in the project root.
The database is stored as `pichost.db`. Create regular full-site backups and keep the `.phost.tar.gz` files on separate storage.
## Tech Stack
| Layer | Technologies |
| ----- | ------------ |
| Frontend | Nuxt 4 · Nuxt UI 4 · Vue 3 · Tailwind CSS 4 · TypeScript |
| Backend | Nitro `node-server` · REST API · H3 |
| Database | SQLite |
| Images | Sharp (WebP compression) |
| Video | FFmpeg · HLS · hls.js |
| Storage | Local disk · S3-compatible object storage · Telegram |
| Internationalization | @nuxtjs/i18n (Simplified Chinese / English) |
| Quality and documentation | Vitest · ESLint · vue-tsc · VitePress |
| Deployment | Docker · Node.js 22 · Electron (Windows) · FnOS FPK |
## License
[GPL-3.0](LICENSE)
## Friend links
[LINUX DO](https://linux.do/)
+154 -42
View File
@@ -4,32 +4,37 @@
<h1 align="center">PicHost</h1>
<p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p>
<p align="center"><strong>自托管图片、视频与文件管理平台</strong></p>
<p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 本地磁盘或对象存储</p>
<p align="center">多用户 · WebP 压缩 · HLS 视频 · 文件床 · 标签 · 多存储后端 · 整站备份与迁移</p>
<p align="center">
<a href="https://github.com/O96u/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<a href="https://github.com/yeomaiyelove-cpu/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/yeomaiyelove-cpu/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<a href="https://hub.docker.com/r/yeomaiyelove/pichost"><img src="https://img.shields.io/badge/Docker-yeomaiyelove%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/Windows-x64-0078D4?style=flat-square&logo=windows&logoColor=white" alt="Windows x64" />
<img src="https://img.shields.io/badge/%E9%A3%9E%E7%89%9B-x86-00A67E?style=flat-square" alt="飞牛 x86" />
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p>
<p align="center">
<a href="https://o96u.github.io/PicHost/">文档</a> ·
<a href="#界面预览">预览</a> ·
<a href="#特性">特性</a> ·
<a href="#技术栈">技术栈</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#核心功能">功能</a> ·
<a href="#部署方式">部署</a> ·
<a href="#开发与打包">开发与打包</a> ·
<a href="docs-site/">文档源码</a> ·
<a href="README.en.md">English</a>
</p>
---
## 界面预览
## 项目简介
**main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
PicHost 是一个面向 NAS、家庭服务器和小型自托管场景的媒体管理平台,可统一管理图片、视频和普通文件。除上传与直链外,还提供多用户、标签、搜索、多存储后端、HLS 视频处理、整站备份恢复、跨后端迁移以及两站点同步。
首次启动通过 Web 向导创建管理员,也可以直接从 `.phost.tar.gz` 完整备份恢复。
## 界面预览
| 上传 | 图库 |
| :--: | :--: |
@@ -51,31 +56,63 @@
| :------: |
| ![操作日志](docs/screenshots/logs.png) |
## 特性
## 核心功能
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
- **多用户**:账号密码登录(滑块 / Turnstile / Cap 人机验证)、可选开放注册;普通用户仅见自己的图片
- **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS)+ Telegram 频道/群组;混合直链 `proxy` / `public`
- **图库**:浏览、搜索、按存储/来源/标签筛选、网格/列表切换、批量删除与批量打标;统计概览与来源分布;点击图片查看详情与多格式链接
- **标签**:每用户独立标签、彩色标记;上传后单张/批量打标;设置页管理(新建、改色、合并、删除);API 支持 `tagIds` 与批量打标
- **备份与迁移**:整站导出/恢复(`.phost.tar.gz`)、跨后端同步;setup 未初始化时可从备份恢复
- **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
- **Docker 零配置**:首次访问 Web 引导创建管理员
### 图片管理
## 技术栈
- 支持拖拽、文件选择和 `Ctrl+V` 粘贴上传,可配置格式、大小、批量数量和频率限制。
- 服务端使用 Sharp 压缩为 WebP,也可保留原图;支持可选的浏览器端 WebP 压缩。
- 图库支持搜索、分页、存储后端/上传来源/标签筛选,以及网格和列表视图。
- 支持查看详情、复制多种链接格式、批量打标和批量删除,并提供媒体数量与来源统计。
- 支持 Referer 防盗链和来源域名白名单。
| 层级 | 技术 |
| ---- | ---- |
| 前端 | [Nuxt 4](https://nuxt.com) · [Nuxt UI 4](https://ui.nuxt.com) · [Vue 3](https://vuejs.org) · [Tailwind CSS 4](https://tailwindcss.com) · TypeScript |
| 后端 | [Nitro](https://nitro.build)(`node-server`)· REST API |
| 数据 | SQLite · 本地 `data/pichost.db` |
| 图片处理 | [sharp](https://sharp.pixelplumbing.com)(服务端 WebP 压缩) |
| 存储 | 本地磁盘 · S3 兼容([AWS SDK](https://aws.amazon.com/sdk-for-javascript/) · R2 / COS / OSS 等)· Telegram 频道/群组 |
| 国际化 | [@nuxtjs/i18n](https://i18n.nuxtjs.org)(简体中文 / English) |
| 质量与文档 | [Vitest](https://vitest.dev) · [VitePress](https://vitepress.dev) 文档站 |
| 部署 | Docker · Node.js 22 |
### 视频库与 HLS
## 快速开始
- 独立的视频上传、搜索和管理页面,支持 MP4、M4V、WebM、MOV、MKV、AVI、FLV、WMV、MPG、MPEG 等格式。
- 上传后自动生成视频缩略图,并可根据设置异步转码为 HLS。
- 支持 `copy`、`veryfast`、`medium`、`slow`、`compress` 等转码档位,可限制并发数、CPU 核心数和目标码率。
- 管理转码进度与失败原因,支持删除时同步清理原视频、缩略图、播放列表和 HLS 分片。
- 支持复制原视频、缩略图和 HLS 地址,并可将 HLS 视频重新封装下载为 MP4。
### 文件床
- 独立管理普通文件,提供上传、搜索、分页、标签、批量删除和直链/下载链接。
- 文件床访问策略可在后台配置,文件、图片和视频可分别使用独立域名。
### 多用户与权限
- 管理员与普通用户账号体系,普通用户只能查看和管理自己的媒体。
- 支持滑块、Cloudflare Turnstile 和 Cap 人机验证,可选择开放注册。
- 支持修改资料、修改密码、管理员清空密码和迁移到账号密码登录。
- 每个用户拥有独立标签和自动删除策略,可设置媒体保存天数。
- 提供登录与上传频率限制、全局/个人 API Token 和操作日志。
### 存储与迁移
- 支持本地磁盘、S3 兼容对象存储和 Telegram 频道/群组。
- S3 兼容 R2、COS、OSS、AWS S3 等服务,支持 `proxy` 和 `public` 两种访问方式。
- 可配置多个存储后端、默认后端、启用状态和容量配额。
- 支持在已配置后端之间迁移图片、视频、文件和索引,迁移后可将目标后端设为默认。
### 备份与站点同步
- 创建和恢复完整 `.phost.tar.gz` 站点备份,包含数据库、图片、视频、文件、缩略图、HLS 内容和标签。
- 备份恢复前可预检文件数量、媒体大小、新增与冲突情况,并选择跳过或覆盖冲突。
- 支持大体积备份流式上传,无需将整个压缩包读入内存。
- 未初始化时可在首次设置页直接恢复备份;云存储密钥不会写入备份,恢复后需重新填写。
- 支持两站点双向同步,可推送或拉取全部媒体,也可只同步图片、视频或文件。
- 站点同步支持断点继续、冲突策略和任务进度;目标站点需要预先存在同名用户。
### API 与兼容性
- 提供图片、视频、文件、标签、搜索、批量操作、备份和系统接口。
- 支持全局 Token 和个人 Token,可通过请求头或表单上传。
- 兼容 Twikoo / EasyImage 的 `POST /api/index.php` 上传接口。
- 前端内置 API 文档和调试界面,支持直接生成并复制 `curl` 示例。
## 部署方式
### Docker
```bash
docker run -d \
@@ -83,22 +120,97 @@ docker run -d \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
yeomaiyelove/pichost:latest
```
浏览器打开 `http://<主机IP>:6892`,按引导完成设置即可。
浏览器打开 `http://<主机IP>:6892`,按向导创建管理员即可。数据默认保存在宿主机的 `./data` 目录。
**详细说明**(环境变量、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。
Docker Hub:
| 分支 | 说明 |
- `yeomaiyelove/pichost:latest`
- `yeomaiyelove/pichost:<版本号>`
### Windows
项目提供 Windows x64 一体安装包:
- 内置 Node.js 和 FFmpeg,不依赖目标电脑预装运行环境。
- 首次启动后按 Web 向导完成设置。
- 数据保存在安装目录下的 `data` 子目录。
- 卸载时会询问是否删除数据;选择保留后,数据库、图片、视频和备份不会被删除。
### 飞牛 NAS
飞牛使用独立的 x86 FPK 安装包,FPK 以内置 Docker 镜像方式运行:
1. 在飞牛 Docker 中导入与 FPK 版本对应的镜像包。
2. 确认镜像列表中存在 `yeomaiyelove/pichost:<版本号>`。
3. 安装 FPK,并按向导选择创建管理员或从备份恢复。
FPK 使用本地镜像,不会自动联网拉取 PicHost 镜像。
## 开发与打包
### 本地开发
```bash
npm ci
npm run dev
```
默认开发地址由 Nuxt 输出,首次访问会进入 `/setup`。常用检查命令:
```bash
npm test
npm run typecheck
npm run lint
npm run build
```
### 构建安装产物
```bash
npm run pack:docker
npm run pack:fpk
npm run pack:windows
```
Windows 环境可以一次构建全部产物:
```bash
npm run pack:all
```
输出位置:
- Docker 离线镜像:`dist-docker/`
- 飞牛安装包:`dist-fpk/`
- Windows 安装程序:`dist-windows/release/`
详细环境要求和可覆盖参数见 [打包说明](packaging/README.md)。
## 数据目录
- Docker 默认使用 `/data`,通过 `DATA_DIR` 可覆盖。
- Windows 安装版使用安装目录下的 `data`。
- 本地开发默认使用项目根目录下的 `data`。
数据库文件为 `pichost.db`。建议定期在后台创建整站备份,并将 `.phost.tar.gz` 文件保存到其他磁盘。
## 技术栈
| 层级 | 技术 |
| ---- | ---- |
| [**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) |
| 前端 | Nuxt 4 · Nuxt UI 4 · Vue 3 · Tailwind CSS 4 · TypeScript |
| 后端 | Nitro `node-server` · REST API · H3 |
| 数据库 | SQLite |
| 图片处理 | Sharp(WebP 压缩) |
| 视频处理 | FFmpeg · HLS · hls.js |
| 存储 | 本地磁盘 · S3 兼容对象存储 · Telegram |
| 国际化 | @nuxtjs/i18n(简体中文 / English) |
| 测试与文档 | Vitest · ESLint · vue-tsc · VitePress |
| 部署 | Docker · Node.js 22 · Electron(Windows)· 飞牛 FPK |
## License
[GPL-3.0](LICENSE)
## 友链
[LINUX DO](https://linux.do/)
+5 -31
View File
@@ -3,6 +3,7 @@ import type {
SiteSyncDirection,
SiteSyncRequest
} from '~/types/backup'
import { loadSiteSyncForm, saveSiteSyncForm } from '~/utils/site-sync-form'
const props = defineProps<{
busy: boolean
@@ -30,42 +31,15 @@ const conflictMode = ref<'skip' | 'overwrite'>('skip')
const direction = ref<SiteSyncDirection>('push')
const scope = ref<'all' | 'image' | 'video' | 'file'>('all')
/** 目标站点地址与 Token 自动保存 24 小时 */
const FORM_STORAGE_KEY = 'pichost.site-sync-form'
const FORM_TTL_MS = 24 * 60 * 60 * 1000
function loadSavedForm() {
if (!import.meta.client) return
try {
const raw = localStorage.getItem(FORM_STORAGE_KEY)
if (!raw) return
const data = JSON.parse(raw) as {
targetBaseUrl?: string
token?: string
savedAt?: number
}
if (!data.savedAt || Date.now() - data.savedAt > FORM_TTL_MS) {
localStorage.removeItem(FORM_STORAGE_KEY)
return
}
if (data.targetBaseUrl) targetBaseUrl.value = data.targetBaseUrl
if (data.token) token.value = data.token
} catch {
localStorage.removeItem(FORM_STORAGE_KEY)
}
const saved = loadSiteSyncForm()
if (saved?.targetBaseUrl) targetBaseUrl.value = saved.targetBaseUrl
if (saved?.token) token.value = saved.token
}
function persistForm() {
if (!import.meta.client) return
try {
localStorage.setItem(FORM_STORAGE_KEY, JSON.stringify({
targetBaseUrl: targetBaseUrl.value,
token: token.value,
savedAt: Date.now()
}))
} catch {
// 存储不可用时忽略
}
saveSiteSyncForm(targetBaseUrl.value, token.value)
}
watch([targetBaseUrl, token], persistForm)
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { loadSiteSyncForm, saveSiteSyncForm } from '~/utils/site-sync-form'
const props = defineProps<{
open: boolean
keys: string[]
@@ -17,6 +19,19 @@ const token = ref('')
const conflictMode = ref<'skip' | 'overwrite'>('skip')
const busy = ref(false)
watch(() => props.open, (open) => {
if (!open) return
const saved = loadSiteSyncForm()
if (saved) {
targetBaseUrl.value = saved.targetBaseUrl
token.value = saved.token
}
}, { immediate: true })
watch([targetBaseUrl, token], () => {
saveSiteSyncForm(targetBaseUrl.value, token.value)
})
const canSubmit = computed(() =>
!busy.value
&& props.keys.length > 0
+13 -6
View File
@@ -5,6 +5,7 @@ import type {
import { isUnauthorizedError } from './useAuth'
import { prepareFilesForUpload } from './useClipboardImage'
import { normalizeUploadTagNames } from './useUploadPreferences'
import { uploadFormDataWithProgress } from '~/utils/upload-progress'
const MAX_FILES = 10
@@ -66,10 +67,10 @@ export function useImageUpload() {
return
}
progressItems.value = prepared.map((file, index) => ({
progressItems.value = prepared.map(file => ({
name: file.name,
status: 'uploading',
progress: Math.round(((index + 1) / prepared.length) * 50)
progress: 0
}))
const formData = new FormData()
@@ -82,10 +83,16 @@ export function useImageUpload() {
formData.append('tagNames', tagNames)
}
const response = await $fetch<UploadResponse>('/api/images/upload', {
method: 'POST',
body: formData
})
const response = await uploadFormDataWithProgress<UploadResponse>(
'/api/images/upload',
formData,
(percent) => {
progressItems.value = progressItems.value.map(item => ({
...item,
progress: Math.min(99, percent)
}))
}
)
lastUploadResult.value = response
+12 -5
View File
@@ -3,6 +3,7 @@ import type { FileItem, FileListResponse, FileUploadResponse } from '~/types/fil
import type { CopyFormat, UploadMediaKind } from './useUploadPreferences'
import { isUnauthorizedError } from './useAuth'
import { normalizeUploadTagNames } from './useUploadPreferences'
import { uploadFormDataWithProgress } from '~/utils/upload-progress'
const MAX_FILES = 10
@@ -255,13 +256,19 @@ export function useMediaUpload(kind: UploadMediaKind) {
progressItems.value = progressItems.value.map(item => ({
...item,
status: 'uploading',
progress: 50
progress: 0
}))
const response = await $fetch<FileUploadResponse>(endpoint, {
method: 'POST',
body: formData
})
const response = await uploadFormDataWithProgress<FileUploadResponse>(
endpoint,
formData,
(percent) => {
progressItems.value = progressItems.value.map(item => ({
...item,
progress: Math.min(99, percent)
}))
}
)
lastResult.value = response
accessMode.value = response.accessMode
+2 -3
View File
@@ -97,11 +97,10 @@ async function submitRestore() {
if (!restoreFile.value || restoreLoading.value) return
restoreLoading.value = true
try {
const form = new FormData()
form.append('file', restoreFile.value)
const data = await $fetch<{ job: { id: string } }>('/api/setup/restore', {
method: 'POST',
body: form
body: restoreFile.value,
headers: { 'content-type': 'application/octet-stream' }
})
restoreJobId.value = data.job.id
restorePollTimer = setInterval(() => {
+198 -17
View File
@@ -1,18 +1,47 @@
<script setup lang="ts">
import { formatFileSize } from '~/composables/useFileSize'
interface AdminUser {
id: number
username: string
role: 'admin' | 'user'
created_at: string
password_reset: number
disabled_at: string | null
usage?: {
imageBytes: number
videoBytes: number
fileBytes: number
}
}
const { isChecking, isAuthenticated, isAdmin, checkSession, fetchStatus, handleAuthError } = useAuth()
const {
isChecking,
isAuthenticated,
isAdmin,
checkSession,
fetchStatus,
handleAuthError,
getFetchErrorMessage
} = useAuth()
const toast = useToast()
const { t } = useI18n()
const users = ref<AdminUser[]>([])
const loading = ref(false)
const deleteTarget = ref<AdminUser | null>(null)
const deleteConfirm = ref('')
const deleting = ref(false)
const statusBusyId = ref<number | null>(null)
const canDelete = computed(() =>
Boolean(
deleteTarget.value
&& deleteTarget.value.role !== 'admin'
&& deleteConfirm.value.trim() === deleteTarget.value.username
&& !deleting.value
)
)
async function loadUsers() {
if (!isAdmin.value) return
@@ -42,7 +71,83 @@ async function clearPassword(user: AdminUser) {
await loadUsers()
} catch (error: unknown) {
handleAuthError(error)
toast.add({ title: t('users.clearFailed'), color: 'error' })
toast.add({
title: getFetchErrorMessage(error, t('users.clearFailed')),
color: 'error'
})
}
}
async function toggleDisabled(user: AdminUser) {
if (user.role === 'admin' || statusBusyId.value !== null) return
const disabled = !user.disabled_at
statusBusyId.value = user.id
try {
await $fetch(`/api/admin/users/${user.id}/status`, {
method: 'POST',
credentials: 'include',
body: { disabled }
})
toast.add({
title: t(disabled ? 'users.disableSuccess' : 'users.enableSuccess', {
name: user.username
}),
color: 'success'
})
await loadUsers()
} catch (error: unknown) {
handleAuthError(error)
toast.add({
title: getFetchErrorMessage(error, t('users.statusFailed')),
color: 'error'
})
} finally {
statusBusyId.value = null
}
}
function openDeleteDialog(user: AdminUser) {
if (user.role === 'admin') return
deleteTarget.value = user
deleteConfirm.value = ''
}
function closeDeleteDialog() {
if (deleting.value) return
deleteTarget.value = null
deleteConfirm.value = ''
}
function onDeleteOpenChange(open: boolean) {
if (!open) closeDeleteDialog()
}
async function deleteUser() {
const target = deleteTarget.value
if (!target || !canDelete.value) return
deleting.value = true
try {
await $fetch(`/api/admin/users/${target.id}/delete`, {
method: 'POST',
credentials: 'include',
body: { confirmation: deleteConfirm.value.trim() }
})
toast.add({
title: t('users.userDeleted', { name: target.username }),
color: 'success'
})
deleteTarget.value = null
deleteConfirm.value = ''
await loadUsers()
} catch (error: unknown) {
handleAuthError(error)
toast.add({
title: getFetchErrorMessage(error, t('users.deleteFailed')),
color: 'error'
})
} finally {
deleting.value = false
}
}
@@ -139,24 +244,100 @@ onMounted(async () => {
</p>
</div>
<UBadge
v-if="user.password_reset === 1"
color="warning"
variant="subtle"
size="sm"
<div
v-if="user.role !== 'admin'"
class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"
>
{{ t('users.resetPending') }}
</UBadge>
<UButton
v-else
variant="outline"
color="warning"
size="sm"
:label="t('users.clearPassword')"
@click="clearPassword(user)"
/>
<span>{{ t('users.usageImages') }} {{ formatFileSize(user.usage?.imageBytes ?? 0) }}</span>
<span>{{ t('users.usageVideos') }} {{ formatFileSize(user.usage?.videoBytes ?? 0) }}</span>
<span>{{ t('users.usageFiles') }} {{ formatFileSize(user.usage?.fileBytes ?? 0) }}</span>
</div>
<div
v-if="user.role !== 'admin'"
class="flex items-center gap-2"
>
<UBadge
v-if="user.disabled_at"
color="error"
variant="subtle"
size="sm"
>
{{ t('users.disabled') }}
</UBadge>
<UBadge
v-if="user.password_reset === 1"
color="warning"
variant="subtle"
size="sm"
>
{{ t('users.resetPending') }}
</UBadge>
<UButton
variant="outline"
:color="user.disabled_at ? 'primary' : 'warning'"
size="sm"
:label="user.disabled_at ? t('users.enable') : t('users.disable')"
:loading="statusBusyId === user.id"
:disabled="statusBusyId !== null && statusBusyId !== user.id"
@click="toggleDisabled(user)"
/>
<UButton
v-if="user.password_reset !== 1"
variant="outline"
color="warning"
size="sm"
:label="t('users.clearPassword')"
@click="clearPassword(user)"
/>
<UButton
variant="outline"
color="error"
size="sm"
:label="t('users.deleteUser')"
@click="openDeleteDialog(user)"
/>
</div>
</li>
</ul>
</AppShell>
<UModal
:open="Boolean(deleteTarget)"
:title="t('users.deleteTitle')"
:description="t('users.deleteWarning')"
@update:open="onDeleteOpenChange"
>
<template #body>
<div class="space-y-2">
<p class="text-sm text-muted">
{{ t('users.deleteConfirmLabel', { name: deleteTarget?.username ?? '' }) }}
</p>
<UInput
v-model="deleteConfirm"
:placeholder="deleteTarget?.username"
class="w-full"
autocomplete="off"
/>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
:disabled="deleting"
@click="closeDeleteDialog"
/>
<UButton
:label="t('users.deleteSubmit')"
color="error"
:loading="deleting"
:disabled="!canDelete"
@click="deleteUser"
/>
</div>
</template>
</UModal>
</div>
</template>
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
loadSiteSyncForm,
saveSiteSyncForm,
SITE_SYNC_FORM_STORAGE_KEY
} from './site-sync-form'
function createStorage(): Storage {
const values = new Map<string, string>()
return {
get length() {
return values.size
},
clear: () => values.clear(),
getItem: key => values.get(key) ?? null,
key: index => [...values.keys()][index] ?? null,
removeItem: key => values.delete(key),
setItem: (key, value) => values.set(key, value)
}
}
describe('site sync form cache', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('reads values saved by another component', () => {
vi.stubGlobal('localStorage', createStorage())
saveSiteSyncForm('http://127.0.0.1:3010', 'secret-token')
expect(loadSiteSyncForm()).toEqual({
targetBaseUrl: 'http://127.0.0.1:3010',
token: 'secret-token'
})
})
it('drops the cache after 24 hours', () => {
const storage = createStorage()
vi.stubGlobal('localStorage', storage)
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-19T00:00:00Z'))
saveSiteSyncForm('http://target', 'token')
vi.setSystemTime(new Date('2026-10-20T00:00:01Z'))
expect(loadSiteSyncForm()).toBeNull()
expect(storage.getItem(SITE_SYNC_FORM_STORAGE_KEY)).toBeNull()
})
})
+52
View File
@@ -0,0 +1,52 @@
export const SITE_SYNC_FORM_STORAGE_KEY = 'pichost.site-sync-form'
export const SITE_SYNC_FORM_TTL_MS = 24 * 60 * 60 * 1000
export interface SiteSyncFormCache {
targetBaseUrl: string
token: string
}
function getStorage(): Storage | null {
return typeof localStorage === 'undefined' ? null : localStorage
}
export function loadSiteSyncForm(): SiteSyncFormCache | null {
const storage = getStorage()
if (!storage) return null
try {
const raw = storage.getItem(SITE_SYNC_FORM_STORAGE_KEY)
if (!raw) return null
const data = JSON.parse(raw) as {
targetBaseUrl?: unknown
token?: unknown
savedAt?: unknown
}
if (typeof data.savedAt !== 'number' || Date.now() - data.savedAt > SITE_SYNC_FORM_TTL_MS) {
storage.removeItem(SITE_SYNC_FORM_STORAGE_KEY)
return null
}
return {
targetBaseUrl: typeof data.targetBaseUrl === 'string' ? data.targetBaseUrl : '',
token: typeof data.token === 'string' ? data.token : ''
}
} catch {
storage.removeItem(SITE_SYNC_FORM_STORAGE_KEY)
return null
}
}
export function saveSiteSyncForm(targetBaseUrl: string, token: string): void {
const storage = getStorage()
if (!storage) return
try {
storage.setItem(SITE_SYNC_FORM_STORAGE_KEY, JSON.stringify({
targetBaseUrl,
token,
savedAt: Date.now()
}))
} catch {
// 存储不可用时忽略
}
}
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { uploadFormDataWithProgress } from './upload-progress'
class FakeXhr {
static current: FakeXhr
upload = {
onprogress: null as ((event: ProgressEvent) => void) | null
}
status = 200
statusText = 'OK'
responseText = '{"ok":true}'
withCredentials = false
onload: (() => void) | null = null
onerror: (() => void) | null = null
constructor() {
FakeXhr.current = this
}
open() {}
send() {}
}
describe('uploadFormDataWithProgress', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('reports the uploaded byte percentage', async () => {
vi.stubGlobal('XMLHttpRequest', FakeXhr)
const onProgress = vi.fn()
const result = uploadFormDataWithProgress<{ ok: boolean }>(
'/upload',
new FormData(),
onProgress
)
FakeXhr.current.upload.onprogress!({
lengthComputable: true,
loaded: 40,
total: 100
} as ProgressEvent)
FakeXhr.current.onload!()
await expect(result).resolves.toEqual({ ok: true })
expect(onProgress).toHaveBeenCalledWith(40)
})
it('keeps the HTTP status and server error message', async () => {
vi.stubGlobal('XMLHttpRequest', FakeXhr)
const result = uploadFormDataWithProgress(
'/upload',
new FormData(),
() => {}
)
FakeXhr.current.status = 401
FakeXhr.current.statusText = 'Unauthorized'
FakeXhr.current.responseText = JSON.stringify({
data: { error: { message: '未登录' } }
})
FakeXhr.current.onload!()
await expect(result).rejects.toMatchObject({
statusCode: 401,
message: '未登录'
})
})
})
+60
View File
@@ -0,0 +1,60 @@
interface UploadError extends Error {
statusCode: number
statusMessage: string
data: unknown
}
function uploadErrorMessage(data: unknown, fallback: string): string {
if (!data || typeof data !== 'object') return fallback
const body = data as {
message?: unknown
error?: { message?: unknown }
data?: { error?: { message?: unknown } }
}
const message = body.data?.error?.message ?? body.error?.message ?? body.message
return typeof message === 'string' ? message : fallback
}
export function uploadFormDataWithProgress<T>(
url: string,
formData: FormData,
onProgress: (percent: number) => void
): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
xhr.withCredentials = true
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress(Math.round((event.loaded / event.total) * 100))
}
}
xhr.onload = () => {
let data: unknown = null
try {
data = xhr.responseText ? JSON.parse(xhr.responseText) : null
} catch {
// 非 JSON 响应交给状态码和状态文本报错
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(data as T)
return
}
const error = new Error(
uploadErrorMessage(data, xhr.statusText || 'Upload failed')
) as UploadError
error.statusCode = xhr.status
error.statusMessage = xhr.statusText
error.data = data
reject(error)
}
xhr.onerror = () => reject(new Error('Network error'))
xhr.send(formData)
})
}
+18 -1
View File
@@ -1283,14 +1283,31 @@
,
"users": {
"title": "Users",
"subtitle": "Review accounts and clear passwords; users set a new one on next login",
"subtitle": "Review accounts, clear passwords, disable, or delete users",
"adminOnly": "Admins only",
"roleAdmin": "Admin",
"roleUser": "User",
"clearPassword": "Clear password",
"deleteUser": "Delete user and clear all data",
"disable": "Disable",
"enable": "Enable",
"disabled": "Disabled",
"disableSuccess": "Disabled user {name}",
"enableSuccess": "Enabled user {name}",
"statusFailed": "Failed to update account status",
"usageImages": "Images",
"usageVideos": "Videos",
"usageFiles": "Files",
"resetPending": "Reset pending",
"passwordCleared": "Cleared password for {name}",
"clearFailed": "Operation failed",
"deleteTitle": "Delete user?",
"deleteWarning": "This permanently deletes the user's current images, videos, files, tags, and login sessions. Admin and other users' data are not affected. This cannot be undone.",
"deleteConfirmLabel": "Type the username {name} to confirm",
"deleteSubmit": "Delete user",
"deleteFailed": "Delete failed",
"userDeleted": "Deleted user {name}",
"adminProtected": "Admin accounts cannot be cleared, disabled, or deleted",
"loadFailed": "Failed to load users"
},
"reset": {
+18 -1
View File
@@ -1286,14 +1286,31 @@
,
"users": {
"title": "用户管理",
"subtitle": "查看账号并清除密码,用户下次登录时重新设置",
"subtitle": "查看账号、清除密码、禁用或注销用户",
"adminOnly": "仅管理员可访问此页面",
"roleAdmin": "管理员",
"roleUser": "普通用户",
"clearPassword": "清除密码",
"deleteUser": "注销该用户并清除所有数据",
"disable": "禁用",
"enable": "启用",
"disabled": "已禁用",
"disableSuccess": "已禁用用户 {name}",
"enableSuccess": "已启用用户 {name}",
"statusFailed": "账号状态修改失败",
"usageImages": "图片",
"usageVideos": "视频",
"usageFiles": "文件",
"resetPending": "待重置密码",
"passwordCleared": "已清除 {name} 的密码",
"clearFailed": "操作失败",
"deleteTitle": "注销用户?",
"deleteWarning": "将永久删除该用户当前的图片、视频、文件、标签和登录会话。管理员数据和其他用户数据不受影响,此操作不可恢复。",
"deleteConfirmLabel": "输入用户名 {name} 确认注销",
"deleteSubmit": "确认注销",
"deleteFailed": "注销失败",
"userDeleted": "已注销用户 {name}",
"adminProtected": "管理员账号不可清除密码、禁用或注销",
"loadFailed": "读取用户列表失败"
},
"reset": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pichost",
"version": "1.5.3",
"version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pichost",
"version": "1.5.3",
"version": "1.5.4",
"hasInstallScript": true,
"license": "GPL-3.0-only",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pichost",
"version": "1.5.3",
"version": "1.5.4",
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme",
"repository": {
+10 -8
View File
@@ -1,4 +1,4 @@
import type { H3Event } from 'h3'
import { readFormData, type H3Event } from 'h3'
import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import { serializeBackupJob } from '../../../utils/backup/api'
@@ -14,6 +14,7 @@ import {
import { createWriteStream, promises as fs } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { readFormDataFiles } from '../../../utils/multipart-form'
interface RestoreBody {
archiveName?: string
@@ -75,21 +76,22 @@ async function resolveRestoreInput(event: H3Event, userId: number): Promise<{
}
if (contentType.includes('multipart/form-data')) {
const form = await readMultipartFormData(event)
const filePart = form?.find(part => part.name === 'file' && part.data?.length)
if (!filePart?.data) {
const form = await readFormData(event)
const filePart = readFormDataFiles(form, ['file'])[0]
if (!filePart) {
createApiError(event, 'INVALID_REQUEST', '请上传备份文件', 400)
}
const conflictField = form?.find(part => part.name === 'conflictMode')?.data?.toString('utf8')
const dryRunField = form?.find(part => part.name === 'dryRun')?.data?.toString('utf8')
const conflictField = form.get('conflictMode')
const dryRunField = form.get('dryRun')
const dryRun = dryRunField === 'true'
const fileBytes = new Uint8Array(await filePart.arrayBuffer())
if (dryRun) {
const tempDir = getBackupStagingDir(`upload-preview-${Date.now()}`)
await fs.mkdir(tempDir, { recursive: true })
const archivePath = join(tempDir, 'package.phost.tar.gz')
await fs.writeFile(archivePath, filePart.data)
await fs.writeFile(archivePath, fileBytes)
return {
archivePath,
body: {
@@ -105,7 +107,7 @@ async function resolveRestoreInput(event: H3Event, userId: number): Promise<{
await fs.mkdir(getBackupsDir(), { recursive: true })
const safeName = `upload-${Date.now()}${BACKUP_ARCHIVE_EXT}`
const archivePath = join(getBackupsDir(), safeName)
await fs.writeFile(archivePath, filePart.data)
await fs.writeFile(archivePath, fileBytes)
return {
archivePath,
body: {
@@ -11,6 +11,9 @@ export default defineEventHandler(async (event) => {
if (!target) {
createApiError(event, 'INVALID_REQUEST', '用户不存在', 404)
}
if (target.role === 'admin') {
createApiError(event, 'FORBIDDEN', '不允许清除管理员账号', 403)
}
if (target.id === operator.id) {
createApiError(event, 'INVALID_REQUEST', '不能清除自己的密码', 400)
}
@@ -0,0 +1,61 @@
import { requireAdminAuth } from '../../../../utils/access'
import { createApiError } from '../../../../utils/api-error'
import { logActivity } from '../../../../utils/activity-log'
import { findUserById } from '../../../../utils/db'
import { deleteUserCurrentData } from '../../../../utils/user-storage'
export default defineEventHandler(async (event) => {
const operator = await requireAdminAuth(event)
const id = Number(getRouterParam(event, 'id'))
const target = Number.isInteger(id) ? findUserById(id) : null
if (!target) {
createApiError(event, 'INVALID_REQUEST', '用户不存在', 404)
}
if (target.role === 'admin') {
createApiError(event, 'FORBIDDEN', '不允许注销管理员账号', 403)
}
if (target.id === operator.id) {
createApiError(event, 'INVALID_REQUEST', '不能注销当前登录账号', 400)
}
const body = await readBody<{ confirmation?: string }>(event).catch(() => null)
if (body?.confirmation?.trim() !== target.username) {
createApiError(event, 'INVALID_REQUEST', '请输入用户名确认注销', 400)
}
try {
const result = await deleteUserCurrentData(target.id)
if (result.failed.length) {
createApiError(
event,
'CONFLICT',
`有 ${result.failed.length} 个媒体删除失败,账号未注销,请重试`,
409
)
}
logActivity(event, {
action: 'delete',
key: `users/${target.id}`,
originalName: '已注销用户',
source: 'admin',
userId: operator.id
})
return { success: true, ...result }
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message === 'USER_STORAGE_CLEAR_IN_PROGRESS') {
createApiError(event, 'CONFLICT', '用户数据正在清理,请勿重复操作', 409)
}
if (error.message === 'USER_STORAGE_CLEAR_ACTIVE_JOB') {
createApiError(event, 'CONFLICT', '该用户有正在运行的任务,请完成后再注销', 409)
}
if (error.message === 'ADMIN_USER_DELETE_FORBIDDEN') {
createApiError(event, 'FORBIDDEN', '不允许注销管理员账号', 403)
}
}
throw error
}
})
@@ -0,0 +1,32 @@
import { requireAdminAuth } from '../../../../utils/access'
import { createApiError } from '../../../../utils/api-error'
import { logActivity } from '../../../../utils/activity-log'
import { findUserById, setUserDisabled } from '../../../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const id = Number(getRouterParam(event, 'id'))
const target = Number.isInteger(id) ? findUserById(id) : null
if (!target) {
createApiError(event, 'INVALID_REQUEST', '用户不存在', 404)
}
if (target.role === 'admin') {
createApiError(event, 'FORBIDDEN', '不允许禁用管理员账号', 403)
}
const body = await readBody<{ disabled?: unknown }>(event).catch(() => null)
if (typeof body?.disabled !== 'boolean') {
createApiError(event, 'INVALID_REQUEST', '无效的账号状态', 400)
}
setUserDisabled(target.id, body.disabled)
logActivity(event, {
action: 'settings',
key: `users/${target.id}/status`,
originalName: target.username,
source: 'admin'
})
return { success: true, disabled: body.disabled }
})
+2 -2
View File
@@ -1,7 +1,7 @@
import { requireAdminAuth } from '../../../utils/access'
import { listAdminUsers } from '../../../utils/db'
import { listAdminUsersWithUsage } from '../../../utils/db'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
return { users: listAdminUsers() }
return { users: listAdminUsersWithUsage() }
})
+5
View File
@@ -57,6 +57,11 @@ export default defineEventHandler(async (event) => {
const user = findUserByUsername(username)
if (user?.disabled_at) {
logWarn('login failed: user disabled', { ip, username })
createApiError(event, 'FORBIDDEN', '该账号已被禁用', 403)
}
// 管理员已清除密码:下次登录时直接进入设置新密码流程
if (user && isPasswordResetRequired(user.id)) {
return { success: false, requiresPasswordReset: true, username: user.username }
+15 -13
View File
@@ -1,4 +1,4 @@
import { readMultipartFormData } from 'h3'
import { readFormData } from 'h3'
import type { FileItem, FileUploadResponse } from '~/types/file'
import type { UploadErrorItem } from '~/types/image'
import {
@@ -11,7 +11,10 @@ import { createApiError } from '../../utils/api-error'
import { checkUploadRateLimit } from '../../utils/rate-limit'
import { processSingleFileUpload } from '../../utils/file-bed'
import { getFileBedAccessMode } from '../../utils/env'
import { readMultipartFormFieldValues } from '../../utils/multipart-form'
import {
readFormDataFieldValues,
readFormDataFiles
} from '../../utils/multipart-form'
import {
parseTagNamesParam,
resolveUploadTagIds
@@ -20,20 +23,18 @@ import {
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
const formData = await readMultipartFormData(event)
if (!formData?.length) {
const formData = await readFormData(event)
if (!formData) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
const tokenField = formData.find(part => part.name === 'token')
const formToken = tokenField?.data?.length
? new TextDecoder().decode(tokenField.data).trim()
: ''
const tokenField = formData.get('token')
const formToken = typeof tokenField === 'string' ? tokenField.trim() : ''
checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event, formToken)
const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames')
const tagNamesRaw = readFormDataFieldValues(formData, 'tagNames')
const uploadTagNames = parseTagNamesParam(
tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw
)
@@ -43,8 +44,9 @@ export default defineEventHandler(async (event) => {
}
const uploadTagIds = resolvedTags.tagIds
const fileParts = formData.filter(
part => (part.name === 'file' || part.name === 'files') && part.data?.length
const fileParts = readFormDataFiles(
formData,
['file', 'files']
)
if (!fileParts.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
@@ -64,8 +66,8 @@ export default defineEventHandler(async (event) => {
for (const part of fileParts) {
const result = await processSingleFileUpload(event, {
bytes: new Uint8Array(part.data),
filename: part.filename,
bytes: new Uint8Array(await part.arrayBuffer()),
filename: part.name,
contentType: part.type,
source,
userId: uploadUserId,
+16 -15
View File
@@ -1,4 +1,4 @@
import { readMultipartFormData } from 'h3'
import { readFormData } from 'h3'
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId } from '../../utils/access'
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
@@ -12,7 +12,10 @@ import {
} from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error'
import { checkUploadRateLimit } from '../../utils/rate-limit'
import { readMultipartFormFieldValues } from '../../utils/multipart-form'
import {
readFormDataFieldValues,
readFormDataFiles
} from '../../utils/multipart-form'
import {
parseTagNamesParam,
resolveUploadTagIds
@@ -21,21 +24,19 @@ import {
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
const formData = await readMultipartFormData(event)
if (!formData?.length) {
const formData = await readFormData(event)
if (!formData) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
const tokenField = formData.find(part => part.name === 'token')
const formToken = tokenField?.data?.length
? new TextDecoder().decode(tokenField.data).trim()
: ''
const tokenField = formData.get('token')
const formToken = typeof tokenField === 'string' ? tokenField.trim() : ''
checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event, formToken)
const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames')
const tagNamesRaw = readFormDataFieldValues(formData, 'tagNames')
const uploadTagNames = parseTagNamesParam(tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw)
const resolvedTags = resolveUploadTagIds(uploadUserId, uploadTagNames)
if ('message' in resolvedTags) {
@@ -44,9 +45,9 @@ export default defineEventHandler(async (event) => {
const uploadTagIds = resolvedTags.tagIds
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter(
part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
&& part.data?.length
const fileParts = readFormDataFiles(
formData,
['image', 'file', 'files']
)
if (!fileParts.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
@@ -65,8 +66,8 @@ export default defineEventHandler(async (event) => {
const errors: UploadErrorItem[] = []
for (const part of fileParts) {
const originalName = part.filename ?? 'image'
const bytes = new Uint8Array(part.data)
const originalName = part.name || 'image'
const bytes = new Uint8Array(await part.arrayBuffer())
const declaredMime = part.type?.toLowerCase() ?? ''
const detectedMime = detectMimeFromSignature(bytes)
@@ -94,7 +95,7 @@ export default defineEventHandler(async (event) => {
const result = await processSingleImageUpload(event, {
bytes,
filename: part.filename,
filename: part.name,
source,
userId: uploadUserId,
tagIds: uploadTagIds
+28 -8
View File
@@ -1,25 +1,45 @@
import { getHeader, readFormData, type H3Event } from 'h3'
import { createApiError } from '../../utils/api-error'
import { isInitialized } from '../../utils/auth'
import { serializeBackupJob } from '../../utils/backup/api'
import { startFullRestoreJob } from '../../utils/backup/runner'
import { BACKUP_ARCHIVE_EXT, getBackupsDir } from '../../utils/backup/paths'
import { readFormDataFiles } from '../../utils/multipart-form'
import { join } from 'node:path'
import { promises as fs } from 'node:fs'
import { createWriteStream, promises as fs } from 'node:fs'
import { pipeline } from 'node:stream/promises'
async function saveRestoreUpload(event: H3Event, archivePath: string): Promise<void> {
const contentType = getHeader(event, 'content-type') ?? ''
try {
if (contentType.includes('application/octet-stream')) {
await pipeline(event.node.req, createWriteStream(archivePath))
} else {
const form = await readFormData(event)
const file = readFormDataFiles(form, ['file'])[0]
if (!file) {
createApiError(event, 'INVALID_REQUEST', '请上传备份文件', 400)
}
await fs.writeFile(archivePath, new Uint8Array(await file.arrayBuffer()))
}
const stat = await fs.stat(archivePath)
if (stat.size === 0) {
createApiError(event, 'INVALID_REQUEST', '备份文件为空', 400)
}
} catch (error) {
await fs.rm(archivePath, { force: true }).catch(() => {})
throw error
}
}
export default defineEventHandler(async (event) => {
if (isInitialized()) {
createApiError(event, 'FORBIDDEN', '系统已初始化,请在存储页恢复备份', 403)
}
const form = await readMultipartFormData(event)
const filePart = form?.find(part => part.name === 'file' && part.data?.length)
if (!filePart?.data) {
createApiError(event, 'INVALID_REQUEST', '请上传备份文件', 400)
}
await fs.mkdir(getBackupsDir(), { recursive: true })
const archivePath = join(getBackupsDir(), `setup-restore-${Date.now()}${BACKUP_ARCHIVE_EXT}`)
await fs.writeFile(archivePath, filePart.data)
await saveRestoreUpload(event, archivePath)
try {
const job = startFullRestoreJob(archivePath, 'overwrite')
+16 -12
View File
@@ -1,4 +1,4 @@
import { readMultipartFormData } from 'h3'
import { getHeader, readFormData } from 'h3'
import { requireSiteSyncAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { MAX_VIDEO_FILE_SIZE } from '../../utils/constants'
@@ -6,23 +6,27 @@ import { findUserByUsername } from '../../utils/db'
import { validateStorageKey } from '../../utils/image-key'
import { getActiveBackend } from '../../utils/storage/resolver'
import { recordSiteSyncArtifact } from '../../utils/site-sync/artifacts'
import { readFormDataFiles } from '../../utils/multipart-form'
function field(form: Awaited<ReturnType<typeof readMultipartFormData>>, name: string): string {
const part = form?.find(item => item.name === name)
return part?.data?.length ? new TextDecoder().decode(part.data).trim() : ''
function field(form: FormData, name: string): string {
const value = form.get(name)
return typeof value === 'string' ? value.trim() : ''
}
export default defineEventHandler(async (event) => {
await requireSiteSyncAuth(event)
const form = await readMultipartFormData(event)
if (!form?.length) createApiError(event, 'INVALID_REQUEST', '未收到同步数据', 400)
const contentLength = Number(getHeader(event, 'content-length') || 0)
if (contentLength > MAX_VIDEO_FILE_SIZE + 1024 * 1024) {
createApiError(event, 'FILE_TOO_LARGE', '同步文件超过 500 MB', 413)
}
const form = await readFormData(event)
const key = field(form, 'key')
const file = form.find(part => part.name === 'file' && part.data?.length)
if (!validateStorageKey(key) || !file?.data) {
const file = readFormDataFiles(form, ['file'])[0]
if (!validateStorageKey(key) || !file) {
createApiError(event, 'INVALID_REQUEST', '同步文件参数无效', 400)
}
if (file.data.length > MAX_VIDEO_FILE_SIZE) {
if (file.size > MAX_VIDEO_FILE_SIZE) {
createApiError(event, 'FILE_TOO_LARGE', '同步文件超过 500 MB', 413)
}
@@ -51,11 +55,11 @@ export default defineEventHandler(async (event) => {
})
}
await backend.put(key, new Uint8Array(file.data), {
originalName: field(form, 'originalName') || file.filename || key.split('/').pop() || 'file',
await backend.put(key, new Uint8Array(await file.arrayBuffer()), {
originalName: field(form, 'originalName') || file.name || key.split('/').pop() || 'file',
uploadedAt: field(form, 'uploadedAt') || new Date().toISOString(),
contentType: file.type || field(form, 'contentType') || 'application/octet-stream',
size: file.data.length,
size: file.size,
userId: owner?.id ?? null,
mediaType: key.startsWith('videos/')
? 'video'
+22 -28
View File
@@ -1,4 +1,4 @@
import { readMultipartFormData } from 'h3'
import { readFormData } from 'h3'
import type { FileItem, FileUploadResponse } from '~/types/file'
import type { UploadErrorItem } from '~/types/image'
import {
@@ -17,7 +17,10 @@ import {
parseTranscodePreset
} from '../../utils/transcode'
import { getFileBedAccessMode } from '../../utils/env'
import { readMultipartFormFieldValues } from '../../utils/multipart-form'
import {
readFormDataFieldValues,
readFormDataFiles
} from '../../utils/multipart-form'
import {
parseTagNamesParam,
resolveUploadTagIds
@@ -26,20 +29,18 @@ import {
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
const formData = await readMultipartFormData(event)
if (!formData?.length) {
const formData = await readFormData(event)
if (!formData) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
const tokenField = formData.find(part => part.name === 'token')
const formToken = tokenField?.data?.length
? new TextDecoder().decode(tokenField.data).trim()
: ''
const tokenField = formData.get('token')
const formToken = typeof tokenField === 'string' ? tokenField.trim() : ''
checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event, formToken)
const tagNamesRaw = readMultipartFormFieldValues(formData, 'tagNames')
const tagNamesRaw = readFormDataFieldValues(formData, 'tagNames')
const uploadTagNames = parseTagNamesParam(
tagNamesRaw.length === 1 ? tagNamesRaw[0] : tagNamesRaw
)
@@ -49,26 +50,19 @@ export default defineEventHandler(async (event) => {
}
const uploadTagIds = resolvedTags.tagIds
const transcodeField = formData.find(part => part.name === 'transcode')
const wantTranscode = transcodeField?.data?.length
? ['1', 'true', 'yes'].includes(
new TextDecoder().decode(transcodeField.data).trim().toLowerCase()
)
: false
const transcodeField = formData.get('transcode')
const wantTranscode = typeof transcodeField === 'string'
&& ['1', 'true', 'yes'].includes(transcodeField.trim().toLowerCase())
const presetField = formData.find(part => part.name === 'transcodePreset')
const presetRaw = presetField?.data?.length
? new TextDecoder().decode(presetField.data).trim()
: ''
const presetField = formData.get('transcodePreset')
const presetRaw = typeof presetField === 'string' ? presetField.trim() : ''
const transcodePreset = presetRaw ? parseTranscodePreset(presetRaw) : undefined
if (presetRaw && !transcodePreset) {
createApiError(event, 'INVALID_REQUEST', '无效的转码质量档位', 400)
}
const bitrateField = formData.find(part => part.name === 'transcodeBitratePercent')
const bitrateRaw = bitrateField?.data?.length
? new TextDecoder().decode(bitrateField.data).trim()
: ''
const bitrateField = formData.get('transcodeBitratePercent')
const bitrateRaw = typeof bitrateField === 'string' ? bitrateField.trim() : ''
const transcodeBitratePercent = bitrateRaw ? Number(bitrateRaw) : undefined
if (
bitrateRaw
@@ -86,9 +80,9 @@ export default defineEventHandler(async (event) => {
)
}
const fileParts = formData.filter(
part => (part.name === 'file' || part.name === 'files' || part.name === 'video')
&& part.data?.length
const fileParts = readFormDataFiles(
formData,
['file', 'files', 'video']
)
if (!fileParts.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
@@ -108,8 +102,8 @@ export default defineEventHandler(async (event) => {
for (const part of fileParts) {
const result = await processSingleVideoUpload(event, {
bytes: new Uint8Array(part.data),
filename: part.filename,
bytes: new Uint8Array(await part.arrayBuffer()),
filename: part.name,
contentType: part.type,
source,
userId: uploadUserId,
+12 -8
View File
@@ -8,13 +8,15 @@ function canOperateImage(
actor: { type: 'user', id: number, role: 'user' | 'admin' }
| { type: 'user-token', id: number }
| { type: 'global-token' },
ownerId: number | null
ownerId: number | null,
primaryAdminId = 1
): boolean {
if (actor.type === 'global-token') return true
if (actor.type === 'global-token') {
return ownerId !== null && ownerId === primaryAdminId
}
if (actor.type === 'user-token') {
return ownerId === actor.id
}
if (actor.role === 'admin') return true
return ownerId !== null && ownerId === actor.id
}
@@ -26,10 +28,11 @@ describe('image ownership rules', () => {
expect(canOperateImage(user1, null)).toBe(false)
})
it('admin can operate any image', () => {
it('admin can only operate own images', () => {
const admin = { type: 'user' as const, id: 1, role: 'admin' as const }
expect(canOperateImage(admin, 2)).toBe(true)
expect(canOperateImage(admin, null)).toBe(true)
expect(canOperateImage(admin, 1)).toBe(true)
expect(canOperateImage(admin, 2)).toBe(false)
expect(canOperateImage(admin, null)).toBe(false)
})
it('user API token is scoped to owner', () => {
@@ -39,10 +42,11 @@ describe('image ownership rules', () => {
expect(canOperateImage(token, null)).toBe(false)
})
it('global API token can operate any image', () => {
it('global API token is scoped to the primary admin owner', () => {
const token = { type: 'global-token' as const }
expect(canOperateImage(token, 1)).toBe(true)
expect(canOperateImage(token, null)).toBe(true)
expect(canOperateImage(token, 2)).toBe(false)
expect(canOperateImage(token, null)).toBe(false)
})
})
+7 -10
View File
@@ -211,12 +211,12 @@ export async function requireApiOrAdminAuth(event: H3Event): Promise<void> {
}
}
/** 列表/搜索/删除时按用户过滤;全局 Token 视为管理员,用户 Token 仅见自己的 */
/** 列表/搜索/删除时按用户过滤;管理员和普通用户都只能访问自己的媒体 */
export async function getImageUserFilter(
event: H3Event
): Promise<number | 'admin'> {
if (devBypass(event)) {
return 'admin'
return getPrimaryAdminUserId() ?? 'admin'
}
const userIdFromToken = resolveUserIdFromApiToken(event)
@@ -225,13 +225,10 @@ export async function getImageUserFilter(
}
if (verifyGlobalApiUploadToken(event)) {
return 'admin'
return getPrimaryAdminUserId() ?? 'admin'
}
const user = await requireUserAuth(event)
if (user.role === 'admin') {
return 'admin'
}
return user.id
}
@@ -253,14 +250,14 @@ export async function assertImageOwnership(
}
if (verifyGlobalApiUploadToken(event)) {
const ownerId = userId ?? null
if (ownerId === null || ownerId !== getPrimaryAdminUserId()) {
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
}
return
}
const user = await requireUserAuth(event)
if (user.role === 'admin') {
return
}
const ownerId = userId ?? null
if (ownerId === null || ownerId !== user.id) {
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
+1 -1
View File
@@ -154,7 +154,7 @@ export async function getCurrentUser(event: H3Event): Promise<AuthUser | null> {
}
const user = findUserById(session.user_id)
if (!user) return null
if (!user || user.disabled_at) return null
return rowToAuthUser(user)
}
+60 -3
View File
@@ -191,6 +191,9 @@ function migrateSchema(database: DatabaseSync): void {
WHERE auto_delete_days > 0 AND auto_delete_enabled_at IS NULL
`)
}
if (!userColumns.some(column => column.name === 'disabled_at')) {
database.exec('ALTER TABLE users ADD COLUMN disabled_at TEXT')
}
const daysRow = database.prepare(`
SELECT value FROM settings WHERE key = 'auto_delete_days'
@@ -263,7 +266,7 @@ function migrateSchema(database: DatabaseSync): void {
`)
}
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at, disabled_at'
export type UserRole = 'admin' | 'user'
@@ -276,6 +279,7 @@ export interface UserRow {
api_token: string | null
auto_delete_days: number
auto_delete_enabled_at: string | null
disabled_at: string | null
}
export interface AutoDeletePolicy {
@@ -330,7 +334,7 @@ export function findUserByApiToken(token: string): UserRow | null {
const row = getDb().prepare(`
SELECT ${USER_COLUMNS}
FROM users
WHERE api_token = ?
WHERE api_token = ? AND disabled_at IS NULL
`).get(token) as UserRow | undefined
return row ?? null
}
@@ -457,17 +461,60 @@ export interface AdminUserRow {
role: UserRole
created_at: string
password_reset: number
disabled_at: string | null
}
export interface AdminUserUsage {
imageBytes: number
videoBytes: number
fileBytes: number
}
/** 管理员用户列表 */
export function listAdminUsers(): AdminUserRow[] {
ensurePasswordResetColumn()
return getDb().prepare(`
SELECT id, username, role, created_at, password_reset
SELECT id, username, role, created_at, password_reset, disabled_at
FROM users ORDER BY id ASC
`).all() as unknown as AdminUserRow[]
}
/** 管理员用户列表,附带图片、视频、文件占用 */
export function listAdminUsersWithUsage(): Array<AdminUserRow & { usage: AdminUserUsage }> {
const usage = new Map<number, AdminUserUsage>()
const rows = getDb().prepare(`
SELECT user_id, media_type, COALESCE(SUM(size), 0) AS bytes
FROM images
WHERE user_id IS NOT NULL
GROUP BY user_id, media_type
`).all() as Array<{ user_id: number, media_type: string, bytes: number }>
for (const row of rows) {
const entry = usage.get(row.user_id) ?? {
imageBytes: 0,
videoBytes: 0,
fileBytes: 0
}
if (row.media_type === 'video') {
entry.videoBytes = Number(row.bytes)
} else if (row.media_type === 'file') {
entry.fileBytes = Number(row.bytes)
} else {
entry.imageBytes = Number(row.bytes)
}
usage.set(row.user_id, entry)
}
return listAdminUsers().map(user => ({
...user,
usage: usage.get(user.id) ?? {
imageBytes: 0,
videoBytes: 0,
fileBytes: 0
}
}))
}
/** 清空密码并标记需要重置 */
export function clearUserPassword(userId: number): void {
ensurePasswordResetColumn()
@@ -484,6 +531,16 @@ export function setUserPassword(userId: number, passwordHash: string): void {
`).run(passwordHash, userId)
}
export function setUserDisabled(userId: number, disabled: boolean): void {
ensurePasswordResetColumn()
getDb().prepare(`
UPDATE users
SET disabled_at = ?
WHERE id = ?
`).run(disabled ? new Date().toISOString() : null, userId)
if (disabled) deleteSessionsForUser(userId)
}
export function updateUsername(userId: number, username: string): void {
getDb().prepare('UPDATE users SET username = ? WHERE id = ?').run(username, userId)
}
+62
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { validateImageKey } from './image-key'
import { insertActivityLog, insertUser } from './db'
import { getMediaStats, insertImageIndex } from './image-index'
import { ensureDefaultBackends } from './storage-backends'
describe('image index key validation', () => {
it('accepts standard image keys used in index', () => {
@@ -16,3 +19,62 @@ describe('image index key validation', () => {
expect(validateImageKey('blog/abc.webp')).toBe(false)
})
})
describe('image stats isolation', () => {
it('scopes upload source counts to the selected user', () => {
ensureDefaultBackends()
const now = new Date().toISOString()
const userA = insertUser({
username: 'stats-user-a',
passwordHash: 'hash-a',
role: 'user',
createdAt: now
})
const userB = insertUser({
username: 'stats-user-b',
passwordHash: 'hash-b',
role: 'user',
createdAt: now
})
insertImageIndex({
key: 'images/2026/10/stats-a.webp',
backendId: 'local',
userId: userA,
originalName: 'stats-a.webp',
contentType: 'image/webp',
size: 1,
uploadedAt: now
})
insertImageIndex({
key: 'images/2026/10/stats-b.webp',
backendId: 'local',
userId: userB,
originalName: 'stats-b.webp',
contentType: 'image/webp',
size: 1,
uploadedAt: now
})
insertActivityLog({
action: 'upload',
key: 'images/2026/10/stats-a.webp',
originalName: 'stats-a.webp',
size: 1,
contentType: 'image/webp',
source: 'web',
userId: userA
})
insertActivityLog({
action: 'upload',
key: 'images/2026/10/stats-b.webp',
originalName: 'stats-b.webp',
size: 1,
contentType: 'image/webp',
source: 'api',
userId: userB
})
expect(getMediaStats('image', userA).bySource).toEqual({ web: 1, api: 0 })
expect(getMediaStats('image', userB).bySource).toEqual({ web: 0, api: 1 })
})
})
+7 -2
View File
@@ -602,11 +602,16 @@ export function getMediaStats(
// 来源分布来自上传日志,按目录前缀区分类型
const prefix = mediaType === 'video' ? 'videos/' : mediaType === 'file' ? 'files/' : 'images/'
const sourceParams: Array<string | number> = [`${prefix}%`]
const sourceUserSql = userFilter === undefined || userFilter === 'admin'
? ''
: ' AND user_id = ?'
if (sourceUserSql) sourceParams.push(userFilter as number)
const sourceRows = getDb().prepare(`
SELECT source, COUNT(*) AS count FROM activity_logs
WHERE action = 'upload' AND key LIKE ?
WHERE action = 'upload' AND key LIKE ?${sourceUserSql}
GROUP BY source
`).all(`${prefix}%`) as Array<{ source: string, count: number }>
`).all(...sourceParams) as Array<{ source: string, count: number }>
const bySource = { web: 0, api: 0 }
for (const row of sourceRows) {
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { readFormData, type H3Event } from 'h3'
import {
readFormDataFieldValues,
readFormDataFiles
} from './multipart-form'
async function parseFormData(form: FormData): Promise<FormData> {
const request = new Request('http://localhost/upload', {
method: 'POST',
body: form
})
return readFormData({ web: { request } } as unknown as H3Event)
}
describe('web FormData helpers', () => {
it('reads text fields and files from a multipart request', async () => {
const form = new FormData()
form.append('key', ' videos/2026/10/test.mov ')
form.append('tagNames', '["a","b"]')
form.append(
'file',
new File([new Uint8Array(1024)], 'test.mov', { type: 'video/quicktime' })
)
const parsed = await parseFormData(form)
expect(parsed.get('key')).toBe(' videos/2026/10/test.mov ')
expect(readFormDataFieldValues(parsed, 'tagNames')).toEqual(['a', 'b'])
expect(readFormDataFiles(parsed, ['file'])[0]).toMatchObject({
name: 'test.mov',
size: 1024,
type: 'video/quicktime'
})
})
it('keeps repeated field values and ignores empty files', async () => {
const form = new FormData()
form.append('tagNames', 'a,b')
form.append('tagNames', 'c')
form.append('files', new File([], 'empty.txt'))
const parsed = await parseFormData(form)
expect(readFormDataFieldValues(parsed, 'tagNames')).toEqual(['a,b', 'c'])
expect(readFormDataFiles(parsed, ['files'])).toEqual([])
})
})
+38 -14
View File
@@ -1,22 +1,28 @@
import type { MultiPartData } from 'h3'
function parseFieldValues(rawValues: string[]): string[] {
const values = rawValues.map(value => value.trim()).filter(Boolean)
if (values.length !== 1) return values
const raw = values[0]!
try {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
return parsed.map(item => String(item).trim()).filter(Boolean)
}
} catch {
// fall through to comma-separated parsing
}
return raw.split(',').map(item => item.trim()).filter(Boolean)
}
export function readMultipartFormFieldValues(
formData: MultiPartData[],
fieldName: string
): string[] {
const field = formData.find(part => part.name === fieldName)
if (field?.data?.length) {
const raw = new TextDecoder().decode(field.data).trim()
if (!raw) return []
try {
const parsed = JSON.parse(raw) as unknown
if (Array.isArray(parsed)) {
return parsed.map(item => String(item).trim()).filter(Boolean)
}
} catch {
// fall through to comma-separated parsing
}
return raw.split(',').map(item => item.trim()).filter(Boolean)
return parseFieldValues([new TextDecoder().decode(field.data)])
}
const repeatFields = formData.filter(
@@ -24,7 +30,25 @@ export function readMultipartFormFieldValues(
)
if (!repeatFields.length) return []
return repeatFields
.map(part => new TextDecoder().decode(part.data!).trim())
.filter(Boolean)
return parseFieldValues(
repeatFields.map(part => new TextDecoder().decode(part.data!))
)
}
export function readFormDataFieldValues(
formData: FormData,
fieldName: string
): string[] {
return parseFieldValues(
formData.getAll(fieldName).filter((value): value is string => typeof value === 'string')
)
}
export function readFormDataFiles(
formData: FormData,
fieldNames: string[]
): File[] {
return fieldNames
.flatMap(name => formData.getAll(name))
.filter((value): value is File => value instanceof File && value.size > 0)
}
+1 -1
View File
@@ -12,7 +12,7 @@ function matchesUserFilter(
}
describe('image user filter', () => {
it('admin sees all images including system uploads', () => {
it('explicit global filter includes all images', () => {
expect(matchesUserFilter({ userId: null } as StoredImageMeta, 'admin')).toBe(true)
expect(matchesUserFilter({ userId: 2 } as StoredImageMeta, 'admin')).toBe(true)
})
+2 -2
View File
@@ -363,13 +363,13 @@ export function resolveTagIdsForImageTagging(
return [...new Set(resolved)]
}
export function assertCanTagImage(key: string, actorUserId: number, isAdmin: boolean): void {
export function assertCanTagImage(key: string, actorUserId: number, _isAdmin: boolean): void {
const indexedKey = resolveIndexedImageKey(key)
if (!indexedKey) {
throw new Error('IMAGE_NOT_FOUND')
}
const ownerId = getImageIndexRow(indexedKey)!.user_id
if (!isAdmin && (ownerId === null || ownerId !== actorUserId)) {
if (ownerId === null || ownerId !== actorUserId) {
throw new Error('FORBIDDEN')
}
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
createSessionRow,
findUserByApiToken,
findUserById,
getDb,
insertUser,
setUserApiToken,
setUserDisabled
} from './db'
describe('user disabled status', () => {
it('blocks API tokens and revokes sessions until re-enabled', () => {
const now = new Date().toISOString()
const userId = insertUser({
username: 'disabled-user',
passwordHash: 'hash',
role: 'user',
createdAt: now
})
const token = 'disabled-user-token'
const sessionId = 'disabled-user-session'
setUserApiToken(userId, token)
createSessionRow({
id: sessionId,
userId,
tokenHash: 'hash',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
createdAt: now
})
setUserDisabled(userId, true)
expect(findUserById(userId)?.disabled_at).not.toBeNull()
expect(findUserByApiToken(token)).toBeNull()
expect(getDb().prepare('SELECT 1 FROM sessions WHERE id = ?').get(sessionId))
.toBeUndefined()
setUserDisabled(userId, false)
expect(findUserById(userId)?.disabled_at).toBeNull()
expect(findUserByApiToken(token)?.id).toBe(userId)
})
})
+80 -2
View File
@@ -5,7 +5,8 @@ import { describe, expect, it } from 'vitest'
import {
getDb,
insertActivityLog,
insertUser
insertUser,
listAdminUsersWithUsage
} from './db'
import {
listImageIndexRowsForUser,
@@ -16,7 +17,7 @@ import { ensureDefaultBackends } from './storage-backends'
import { getActiveBackend } from './storage/resolver'
import { putImage } from './storage'
import { createTag } from './tags'
import { clearUserStorage } from './user-storage'
import { clearUserStorage, deleteUserCurrentData } from './user-storage'
import {
createBackupJob,
getBackupJob,
@@ -142,4 +143,81 @@ describe('clearUserStorage', () => {
await expect(fs.access(archiveA)).rejects.toThrow()
await expect(fs.access(archiveB)).resolves.toBeUndefined()
})
it('deletes current user data without deleting backups or admin audit data', async () => {
ensureDefaultBackends()
const now = new Date().toISOString()
const admin = insertUser({
username: 'delete-admin',
passwordHash: 'admin-hash',
role: 'admin',
createdAt: now
})
const user = insertUser({
username: 'delete-user',
passwordHash: 'user-hash',
role: 'user',
createdAt: now
})
const key = 'images/2026/10/delete-user.webp'
const target = await getActiveBackend()
await putImage(key, new Uint8Array([1]), {
originalName: 'delete-user.webp',
uploadedAt: now,
contentType: 'image/webp',
size: 1,
userId: user,
mediaType: 'image'
})
const tag = createTag(user, 'delete tag')
getDb().prepare('INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)')
.run(key, tag.id)
getDb().prepare(`
INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)
`).run('delete-session', user, 'hash', now, now)
insertActivityLog({
action: 'upload',
key,
originalName: 'delete-user.webp',
size: 1,
contentType: 'image/webp',
source: 'web',
userId: user
})
insertActivityLog({
action: 'settings',
key: `users/${user}/clear-password`,
originalName: 'delete-user',
size: 0,
contentType: '',
source: 'admin',
userId: admin
})
const job = createBackupJob({ type: 'full_export', userId: user })
markJobDone(job.id, {})
const listed = listAdminUsersWithUsage().find(item => item.id === user)
expect(listed?.usage.imageBytes).toBe(1)
const result = await deleteUserCurrentData(user)
expect(result.failed).toEqual([])
expect(result.deletedMedia).toBe(1)
expect(result.deletedTags).toBe(1)
expect(await target.head(key)).toBeNull()
expect(getDb().prepare('SELECT 1 FROM users WHERE id = ?').get(user)).toBeUndefined()
expect(getDb().prepare('SELECT 1 FROM sessions WHERE user_id = ?').get(user)).toBeUndefined()
expect(getDb().prepare('SELECT 1 FROM images WHERE user_id = ?').get(user)).toBeUndefined()
expect(getDb().prepare('SELECT 1 FROM tags WHERE user_id = ?').get(user)).toBeUndefined()
expect(getDb().prepare('SELECT 1 FROM activity_logs WHERE user_id = ?').get(user)).toBeUndefined()
expect(getDb().prepare(`
SELECT original_name FROM activity_logs
WHERE user_id = ? AND key = ?
`).get(admin, `users/${user}/clear-password`)).toEqual({ original_name: '已注销用户' })
expect(getDb().prepare('SELECT 1 FROM backup_jobs WHERE id = ?').get(job.id)).toBeDefined()
await expect(deleteUserCurrentData(admin)).rejects.toThrow('ADMIN_USER_DELETE_FORBIDDEN')
})
})
+47 -3
View File
@@ -34,6 +34,11 @@ export interface ClearUserStorageResult {
failed: Array<{ key: string, message: string }>
}
export interface ClearUserStorageOptions {
/** 用户注销时保留备份任务,避免影响管理员的备份数据 */
deleteBackupJobs?: boolean
}
async function deleteObject(
backend: StorageBackend,
key: string
@@ -73,7 +78,11 @@ async function removeBackupJobArtifacts(job: BackupJob): Promise<void> {
}
}
export async function clearUserStorage(userId: number): Promise<ClearUserStorageResult> {
export async function clearUserStorage(
userId: number,
options: ClearUserStorageOptions = {}
): Promise<ClearUserStorageResult> {
const deleteBackupJobs = options.deleteBackupJobs !== false
if (activeUsers.has(userId)) {
throw new Error('USER_STORAGE_CLEAR_IN_PROGRESS')
}
@@ -94,7 +103,7 @@ export async function clearUserStorage(userId: number): Promise<ClearUserStorage
throw new Error('USER_STORAGE_CLEAR_ACTIVE_JOB')
}
const backupJobs = listAllBackupJobsForUser(userId)
const backupJobs = deleteBackupJobs ? listAllBackupJobsForUser(userId) : []
const rows = listImageIndexRowsForUser(userId)
for (const row of rows) {
try {
@@ -158,7 +167,7 @@ export async function clearUserStorage(userId: number): Promise<ClearUserStorage
}
}
if (!result.failed.length) {
if (!result.failed.length && deleteBackupJobs) {
for (const job of backupJobs) {
await removeBackupJobArtifacts(job)
}
@@ -171,3 +180,38 @@ export async function clearUserStorage(userId: number): Promise<ClearUserStorage
activeUsers.delete(userId)
}
}
/** 管理员注销用户:清理当前有效数据,但保留备份与管理员审计数据 */
export async function deleteUserCurrentData(
userId: number
): Promise<ClearUserStorageResult> {
const db = getDb()
const target = db.prepare('SELECT id, username, role FROM users WHERE id = ?')
.get(userId) as { id: number, username: string, role: string } | undefined
if (!target) throw new Error('USER_NOT_FOUND')
if (target.role === 'admin') throw new Error('ADMIN_USER_DELETE_FORBIDDEN')
// 先终止会话,避免清理过程中继续上传;失败时用户仍可重新登录。
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId)
const result = await clearUserStorage(userId, { deleteBackupJobs: false })
if (result.failed.length) return result
db.exec('BEGIN')
try {
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId)
db.prepare(`
UPDATE activity_logs
SET original_name = '已注销用户'
WHERE key LIKE ?
`).run(`users/${userId}/%`)
db.prepare('DELETE FROM users WHERE id = ?').run(userId)
db.exec('COMMIT')
} catch (error) {
db.exec('ROLLBACK')
throw error
}
return result
}
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
PORT=3000
LOG_DIR="$ROOT/logs"
LOG_FILE="$LOG_DIR/dev.log"
PID_FILE="$LOG_DIR/dev.pid"
cd "$ROOT"
if ! command -v node >/dev/null 2>&1; then
echo "未找到 Node.js,请先安装 Node.js 22 或更高版本。"
exit 1
fi
if ! command -v npm >/dev/null 2>&1; then
echo "未找到 npm,请检查 Node.js 安装。"
exit 1
fi
if [[ ! -d node_modules ]]; then
echo "正在安装项目依赖..."
npm ci
fi
if [[ ! -f .env && -f .env.example ]]; then
cp .env.example .env
fi
mkdir -p "$LOG_DIR"
if lsof -tiTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
echo "端口 $PORT 已被占用,请先运行 stop-dev.command 或检查其他程序。"
exit 1
fi
echo "正在启动 PicHost..."
nohup npm run dev >"$LOG_FILE" 2>&1 &
echo $! >"$PID_FILE"
for _ in {1..60}; do
if curl -fsS -o /dev/null "http://127.0.0.1:$PORT/" 2>/dev/null; then
echo "PicHost 已启动:http://127.0.0.1:$PORT/"
open "http://127.0.0.1:$PORT/"
exit 0
fi
if ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "PicHost 启动失败,最后日志:"
tail -n 40 "$LOG_FILE" 2>/dev/null || true
rm -f "$PID_FILE"
exit 1
fi
sleep 1
done
echo "PicHost 启动超时,请查看日志:$LOG_FILE"
exit 1
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
PORT=3000
PID_FILE="$ROOT/logs/dev.pid"
cd "$ROOT"
PIDS="$(lsof -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true)"
if [[ -z "$PIDS" ]]; then
rm -f "$PID_FILE"
echo "PicHost 未运行。"
exit 0
fi
for pid in $PIDS; do
command_line="$(ps -o command= -p "$pid" 2>/dev/null || true)"
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1)"
if [[ "$command_line" != *"$ROOT"* && "$cwd" != "$ROOT" ]]; then
echo "端口 $PORT 属于其他程序,已取消停止。"
exit 1
fi
kill "$pid" 2>/dev/null || true
done
for _ in {1..20}; do
if ! lsof -tiTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
break
fi
sleep 0.5
done
if lsof -tiTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then
for pid in $(lsof -tiTCP:"$PORT" -sTCP:LISTEN 2>/dev/null || true); do
kill -9 "$pid" 2>/dev/null || true
done
fi
if [[ -f "$PID_FILE" ]]; then
kill "$(cat "$PID_FILE")" 2>/dev/null || true
rm -f "$PID_FILE"
fi
echo "PicHost 已停止。"
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['server/**/*.test.ts'],
include: ['server/**/*.test.ts', 'app/utils/**/*.test.ts'],
pool: 'forks',
setupFiles: ['./server/test/setup.ts']
}