Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d3acfbb5 | ||
|
|
ed0f20b754 | ||
|
|
ccfdac2e1b | ||
|
|
dd0005b7ba | ||
|
|
366b8a4272 | ||
|
|
58424fe55d | ||
|
|
3759422222 | ||
|
|
885534378b |
No files matched your search
+152
-41
@@ -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/)
|
||||
@@ -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 @@
|
||||
| :------: |
|
||||
|  |
|
||||
|
||||
## 特性
|
||||
## 核心功能
|
||||
|
||||
- **拖拽 / 点击 / 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/)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
+184
-3
@@ -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,6 +244,26 @@ onMounted(async () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="user.role !== 'admin'"
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted"
|
||||
>
|
||||
<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"
|
||||
@@ -148,15 +273,71 @@ onMounted(async () => {
|
||||
{{ t('users.resetPending') }}
|
||||
</UBadge>
|
||||
<UButton
|
||||
v-else
|
||||
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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
// 存储不可用时忽略
|
||||
}
|
||||
}
|
||||
@@ -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: '未登录'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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() }
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { MultiPartData } from 'h3'
|
||||
|
||||
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 []
|
||||
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)) {
|
||||
@@ -17,6 +14,15 @@ export function readMultipartFormFieldValues(
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Executable
+58
@@ -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
|
||||
Executable
+45
@@ -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
@@ -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']
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user