release: PicHost v1.0.0

Multi-user image hosting with RBAC, upload preferences, and admin settings.
This commit is contained in:
O96u committed 2026-08-22 14:36:57 +08:00
commit bc4cba7e52
115 files changed
+26508

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
node_modules
.output
.nuxt
.git
.github
data
dist
.env
*.md
+13
View File
@@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+27
View File
@@ -0,0 +1,27 @@
# PicHost 环境变量
# 本地开发:复制为 .env 后 npm run dev,首次访问按 /setup 引导创建管理员即可
# 生产环境:docker-compose.yml 中配置,或挂载 /data 后首次访问 Web 引导
# 外部脚本上传 Token(Twikoo / 油猴 / 博客用,可选)
# 请求头:Auth-Token: <token>(表单上传仍可用 token 字段)
# 未配置时可在后台「API」页生成
# API_UPLOAD_TOKEN=
# 防盗链白名单:允许引用图片的站点域名,逗号分隔(自身域名自动放行)
# 可选;不填可在设置页配置。若在此填写则覆盖后台设置
# ALLOWED_REFERER_HOSTS=example.com,www.example.com
# 图片直链域名(可选;不填则使用请求域名,也可在设置页配置)
# IMAGE_BASE_URL=https://pic.example.com
# 图片存储目录(默认 ./data,容器内为 /data)
# DATA_DIR=./data
# 服务端 WebP 压缩质量 1-100(默认 80;也可在设置页配置)
# WEBP_QUALITY=80
# 自动删除超过 N 天的图片(0 表示关闭;也可在设置页配置)
# AUTO_DELETE_DAYS=0
# 仅本地开发:设为 true 可绕过登录验证(跳过 /setup,一般无需开启)
# DEV_BYPASS_ACCESS=false
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
*.{ts,vue,js,mjs,cjs,json,jsonc,yml,yaml,md,css} text eol=lf
+15
View File
@@ -0,0 +1,15 @@
{
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme",
"topics": [
"docker",
"image-hosting",
"nas",
"nuxt",
"nuxt4",
"self-hosted",
"twikoo",
"easyimage",
"typescript"
]
}
+39
View File
@@ -0,0 +1,39 @@
name: ci
on: push
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [22]
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install npm 11 (lockfile generated with npm 11)
run: |
npm install -g npm@11.12.1
npm --version
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Test
run: npm run test
+83
View File
@@ -0,0 +1,83 @@
name: Publish Docker image
on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: read
packages: write
env:
IMAGE_NAME: pichost
jobs:
build-and-push:
if: ${{ !contains(github.event.head_commit.message, '[skip docker]') }}
runs-on: ubuntu-latest
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != ''
uses: docker/login-action@v3
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Resolve image names
id: images
run: |
names="ghcr.io/${OWNER}/${IMAGE_NAME}"
if [ -n "${DOCKERHUB_USERNAME}" ]; then
names="${names}
${DOCKERHUB_USERNAME}/${IMAGE_NAME}"
fi
{
echo "list<<EOF"
echo "${names}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
env:
OWNER: ${{ github.repository_owner }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.images.outputs.list }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern=v{{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+38
View File
@@ -0,0 +1,38 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
!server/api/logs/
!server/api/logs/**
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# 本地图片存储目录
data
# Wrangler(main 分支遗留的本地缓存)
.wrangler
# Cursor 本地规则(不上传,避免进仓库)
.cursor/
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
git-hooks/
+29
View File
@@ -0,0 +1,29 @@
# Changelog
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [1.0.0] - 2026-08-22
### 新增
- 多用户账号体系:Web 引导创建管理员、用户名密码登录、可选开放注册
- 角色权限:管理员 / 普通用户;图库按 `userId` 隔离,服务端强制校验
- 个人 API Token:每用户独立 Token,脚本上传归属对应账号
- 上传偏好:客户端预压缩、WebP 质量、自动复制链接、按用户自动删除策略
- 管理员系统设置:注册开关、防盗链、访问域名、默认上传目录
- 统计页:图库浏览 / 搜索 / 批量删除;管理员显示注册用户数量
- Twikoo / EasyImage 2.0 兼容:`POST /api/index.php`
- 遗留 `ADMIN_SECRET` 一次性迁移至账号密码
### 技术栈
- Nuxt 4 + Nuxt UI 4
- 本地磁盘存储 + SQLite
- sharp 服务端 WebP 压缩
- Docker 多架构镜像(amd64 / arm64)
### 路线图
- **v1.1.0**(计划中):S3、Cloudflare R2 及更多对象存储后端
[1.0.0]: https://github.com/O96u/PicHost/releases/tag/v1.0.0
+27
View File
@@ -0,0 +1,27 @@
FROM node:22-alpine AS build
WORKDIR /app
# package-lock.json 由 npm 11 生成;镜像自带 npm 10,需对齐后再 npm ci
RUN npm install -g npm@11.12.1
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production \
NITRO_PORT=6892 \
DATA_DIR=/data
COPY --from=build /app/.output ./.output
EXPOSE 6892
VOLUME /data
CMD ["node", ".output/server/index.mjs"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 O96u
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+188
View File
@@ -0,0 +1,188 @@
<p align="center">
<img src="app/assets/image/logo-light.png" alt="PicHost" width="96" />
</p>
<h1 align="center">PicHost</h1>
<p align="center"><strong>Lightweight personal image hosting</strong> · Simple uploads, clean management</p>
<p align="center">Self-hosted / Multi-user / Docker / API / Twikoo · Zero-config setup</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>
<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>
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
<a href="#tech-stack">Tech Stack</a> ·
<a href="#roadmap">Roadmap</a> ·
<a href="README.md">简体中文</a>
</p>
---
## Screenshots
| Login | Upload |
|:---:|:---:|
| ![Login](docs/screenshots/login.png) | ![Upload](docs/screenshots/upload.png) |
| Upload preferences | Stats & gallery |
|:---:|:---:|
| ![Preferences](docs/screenshots/preferences.png) | ![Stats](docs/screenshots/stats.png) |
| Settings | |
|:---:|:---:|
| ![Settings](docs/screenshots/settings.png) | |
## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL
- **Multi-user** — username/password login; optional registration; users see only their images
- **Upload preferences** (card flip UI): client-side compression, auto-copy links, per-user auto-delete
- **Gallery** — browse, search, batch delete; admin sees uploader badges
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count
- **API** — global token (admin) + per-user tokens; in-app cURL snippets
- **Twikoo** — `POST /api/index.php` (`image` + `token`)
- **Zero-config Docker** — first-run web wizard, no secrets required upfront
## Quick Start
### Docker (recommended)
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
docker compose up -d
```
Default port: **6892**. Complete the setup wizard in the browser. Image: `muxui/pichost:latest` (fallback: `ghcr.io/o96u/pichost:latest`).
### Local development
```bash
npm install
cp .env.example .env # optional
npm run dev
```
Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`.
### Legacy migration
If you only had `ADMIN_SECRET` and no user table, log in with the secret once and follow the migration wizard.
## Tech Stack
| Layer | Technology |
|-------|------------|
| Frontend | Nuxt 4, Vue 3, Nuxt UI 4, Tailwind CSS 4 |
| Backend | Nitro (Node.js), SQLite |
| Images | sharp (WebP transcoding & compression) |
| Auth | Session cookie, scrypt password hashing, API tokens |
| Storage | Local disk (`DATA_DIR`) |
| Deploy | Docker (amd64 / arm64), docker compose |
| Tests | Vitest |
## Environment variables
| Variable | Description |
|----------|-------------|
| `API_UPLOAD_TOKEN` | Global upload token (`Auth-Token` header); can be generated in the API page |
| `ALLOWED_REFERER_HOSTS` | Hotlink allowlist (comma-separated hostnames) |
| `IMAGE_BASE_URL` | Public base URL for image links |
| `WEBP_QUALITY` | Server WebP quality 1–100, default 80 |
| `AUTO_DELETE_DAYS` | Global auto-delete days (0 = off); admin + orphan images only |
| `DATA_DIR` | Data directory, default `/data` (container) or `./data` (local) |
| `ADMIN_SECRET` | Legacy v1.0 migration only |
| `DEV_BYPASS_ACCESS` | Dev only — bypass auth (never in production) |
Environment variables override database settings. See [`.env.example`](.env.example).
## Users & permissions
| Scenario | Auth | Ownership / visibility |
|----------|------|------------------------|
| Web upload | Session | Current user |
| API upload + **user token** | `Auth-Token` | Token owner |
| API upload + **global token** | `Auth-Token` | Admin (`userId` null) |
| Twikoo `/api/index.php` | form `token` | Same as global token |
| Gallery list / search / delete | Session or token | Users: own only; admin: all |
| Direct link `GET /images/...` | None (Referer rules) | Public if URL is known |
Regular users are limited to the `images/` folder; custom `folder` requires admin or global token.
## Storage layout
```
/data
├── pichost.db # SQLite: users, sessions, settings, logs
├── images/ # Default upload folder
├── blog/ # Example custom folder
└── twikoo/ # Twikoo comment images
```
Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. Back up the entire `/data` directory.
## API overview
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/images/upload` | Upload image |
| GET | `/api/images` | Paginated list |
| DELETE | `/api/images` | Delete one (`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
Upload example:
```bash
curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png"
```
Full docs and copyable cURL snippets are on the **API** page after deployment.
## Twikoo
| Setting | Value |
|---------|-------|
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://your-domain/api/index.php` |
| `IMAGE_CDN_TOKEN` | Same as `API_UPLOAD_TOKEN` |
## Reverse proxy
- Proxy to port **6892**; HTTPS recommended
- Nginx: `client_max_body_size` ≥ 12m
- Set `IMAGE_BASE_URL` for public access
## Development
```bash
npm run dev # Dev server
npm run build # Production build
npm run start # Run .output
npm run lint # ESLint
npm run typecheck # Type check
npm test # Unit tests
```
## Roadmap
| Version | Plan |
|---------|------|
| **v1.0.0** | Local storage, multi-user, API, Twikoo (current) |
| **v1.1.0** | S3, Cloudflare R2, and more object storage backends |
## License
[MIT](LICENSE)
+188
View File
@@ -0,0 +1,188 @@
<p align="center">
<img src="app/assets/image/logo-light.png" alt="PicHost" width="96" />
</p>
<h1 align="center">PicHost</h1>
<p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p>
<p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 零配置引导</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>
<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>
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
</p>
<p align="center">
<a href="#快速开始">快速开始</a> ·
<a href="#界面预览">预览</a> ·
<a href="#特性">特性</a> ·
<a href="#技术栈">技术栈</a> ·
<a href="#路线图">路线图</a> ·
<a href="README.en.md">English</a>
</p>
---
## 界面预览
| 登录 | 上传 |
|:---:|:---:|
| ![登录](docs/screenshots/login.png) | ![上传](docs/screenshots/upload.png) |
| 上传偏好 | 统计与图库 |
|:---:|:---:|
| ![上传偏好](docs/screenshots/preferences.png) | ![统计](docs/screenshots/stats.png) |
| 系统设置 | |
|:---:|:---:|
| ![设置](docs/screenshots/settings.png) | |
## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片
- **上传偏好**(卡片翻转设置):客户端预压缩、自动复制链接、按用户自动删除
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
- **Twikoo**:`POST /api/index.php`(`image` + `token`)
- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥
## 快速开始
### Docker(推荐)
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
docker compose up -d
```
默认端口 **6892**。浏览器打开后按引导创建管理员即可。镜像:`muxui/pichost:latest`(备用 `ghcr.io/o96u/pichost:latest`)。
### 本地开发
```bash
npm install
cp .env.example .env # 可选
npm run dev
```
访问 `http://localhost:3000/setup` 创建管理员;数据目录默认 `./data`。
### 遗留部署迁移
若旧版仅配置了 `ADMIN_SECRET`、尚无用户表,可用密钥登录一次,Web 引导迁移为账号密码。
## 技术栈
| 层级 | 技术 |
|------|------|
| 前端 | Nuxt 4、Vue 3、Nuxt UI 4、Tailwind CSS 4 |
| 后端 | Nitro(Node.js)、SQLite |
| 图片处理 | sharp(WebP 转码与压缩) |
| 鉴权 | Session Cookie、scrypt 密码哈希、API Token |
| 存储 | 本地磁盘(`DATA_DIR`) |
| 部署 | Docker(amd64 / arm64)、docker compose |
| 测试 | Vitest |
## 环境变量
| 变量 | 说明 |
|------|------|
| `API_UPLOAD_TOKEN` | 全局上传 Token(`Auth-Token` 头);未配置可在后台 API 页生成 |
| `ALLOWED_REFERER_HOSTS` | 防盗链白名单(逗号分隔 hostname) |
| `IMAGE_BASE_URL` | 图片直链公网域名(反代时建议填写) |
| `WEBP_QUALITY` | 服务端 WebP 质量 1–100,默认 80 |
| `AUTO_DELETE_DAYS` | 全局自动删除天数(0 关闭);仅影响管理员及无归属历史图 |
| `DATA_DIR` | 数据目录,默认 `/data`(容器)或 `./data`(本地) |
| `ADMIN_SECRET` | 仅遗留 v1.0 迁移,新安装不需要 |
| `DEV_BYPASS_ACCESS` | 开发用,跳过登录(生产勿开) |
环境变量优先级高于后台设置。详见 [`.env.example`](.env.example)。
## 用户与权限
| 场景 | 鉴权 | 图片归属 / 可见范围 |
|------|------|---------------------|
| 网页上传 | Session | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` | 该 Token 所属用户 |
| API 上传 + **全局 Token** | `Auth-Token` | 管理员(`userId` 为空) |
| Twikoo `/api/index.php` | 表单 `token` | 与全局 Token 相同 |
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
普通用户上传目录固定为 `images/`;自定义 `folder` 仅管理员或全局 Token 可用。
## 存储结构
```
/data
├── pichost.db # SQLite:用户、会话、设置、操作日志
├── images/ # 默认上传目录
├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片
```
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。备份整个 `/data` 即可。
## API 概览
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/images/upload` | 上传图片 |
| GET | `/api/images` | 分页列表 |
| DELETE | `/api/images` | 删除单张(`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
上传示例:
```bash
curl -X POST "https://pic.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "folder=blog" \
-F "image=@./demo.png"
```
完整接口与可复制 cURL 见部署后后台 **API** 页。
## Twikoo
| 配置项 | 值 |
|--------|-----|
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://你的域名/api/index.php` |
| `IMAGE_CDN_TOKEN` | 与 `API_UPLOAD_TOKEN` 相同 |
## 反向代理
- 反代到容器 **6892** 端口,建议 HTTPS
- Nginx:`client_max_body_size` ≥ 12m
- 公网访问请配置 `IMAGE_BASE_URL`
## 开发
```bash
npm run dev # 开发服务器
npm run build # 生产构建
npm run start # 运行 .output
npm run lint # ESLint
npm run typecheck # 类型检查
npm test # 单元测试
```
## 路线图
| 版本 | 计划 |
|------|------|
| **v1.0.0** | 本地存储、多用户、API、Twikoo(当前) |
| **v1.1.0** | S3、Cloudflare R2 等对象存储后端 |
## License
[MIT](LICENSE)
+8
View File
@@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'green',
neutral: 'slate'
}
}
})
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
useHead({
title: 'PicHost - 个人图床',
meta: [
{ name: 'description', content: '个人自用的轻量图床系统' }
]
})
</script>
<template>
<UApp>
<NuxtPage />
</UApp>
</template>
+63
View File
@@ -0,0 +1,63 @@
@import "tailwindcss";
@import "@nuxt/ui";
.upload-card-flip {
perspective: 1200px;
}
.upload-card-flip-inner {
position: relative;
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
transform-style: preserve-3d;
}
.upload-card-flip.is-flipped .upload-card-flip-inner {
transform: rotateY(180deg);
}
.upload-card-face {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.upload-card-face--back {
position: absolute;
inset: 0;
transform: rotateY(180deg);
}
.upload-card-surface {
min-height: 20.5rem;
border-radius: 1rem;
border-width: 2px;
border-style: dashed;
border-color: var(--ui-border);
background-color: color-mix(in oklab, var(--ui-bg-elevated) 50%, transparent);
padding: 2.5rem;
}
.upload-card-surface--panel {
border-style: solid;
background-color: var(--ui-bg-elevated);
box-shadow: var(--ui-shadow-sm);
padding: 0;
}
@media (min-width: 640px) {
.upload-card-surface {
min-height: 22rem;
padding: 3rem;
}
.upload-card-surface--panel {
padding: 0;
}
}
.upload-settings-btn .upload-settings-icon {
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}
.upload-settings-btn:hover .upload-settings-icon {
transform: rotate(90deg);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 KiB

+194
View File
@@ -0,0 +1,194 @@
<script setup lang="ts">
import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png'
const emit = defineEmits<{
success: []
}>()
const router = useRouter()
const { login, fetchStatus, authStatus } = useAuth()
const toast = useToast()
const username = ref('')
const password = ref('')
const secret = ref('')
const loading = ref(false)
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
const canSubmit = computed(() =>
legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value)
)
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await router.replace('/setup')
}
})
async function submit() {
if (!canSubmit.value || loading.value) return
loading.value = true
try {
const result = legacyMode.value
? await login({ secret: secret.value })
: await login({ username: username.value.trim(), password: password.value })
if (result.ok) {
if (result.needsMigration) {
await router.replace('/setup?migrate=1')
return
}
username.value = ''
password.value = ''
secret.value = ''
emit('success')
toast.add({ title: '登录成功', color: 'success' })
} else {
toast.add({
title: result.error ?? (legacyMode.value ? '登录失败,请检查密钥' : '用户名或密码错误'),
color: 'error'
})
}
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8">
<div class="mb-8 space-y-3 text-center">
<div class="mx-auto inline-flex items-center justify-center">
<img
:src="logoLight"
alt=""
aria-hidden="true"
class="h-14 w-auto shrink-0 object-contain dark:hidden sm:h-16"
decoding="async"
draggable="false"
>
<img
:src="logoDark"
alt=""
aria-hidden="true"
class="hidden h-14 w-auto shrink-0 object-contain dark:block sm:h-16"
decoding="async"
draggable="false"
>
</div>
<div class="space-y-1">
<h1 class="text-xl font-bold tracking-tight sm:text-2xl">
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</h1>
<p class="text-sm text-muted">
个人轻量图床
</p>
</div>
</div>
<form
class="flex w-full flex-col gap-5"
@submit.prevent="submit"
>
<template v-if="legacyMode">
<div class="w-full space-y-2">
<label
for="admin-secret"
class="block text-sm font-medium text-default"
>
管理密钥
</label>
<UInput
id="admin-secret"
v-model="secret"
type="password"
placeholder="输入 ADMIN_SECRET"
autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
@keyup.enter="submit"
/>
<p class="text-xs leading-relaxed text-muted">
遗留部署:使用密钥登录后将引导创建账号
</p>
</div>
</template>
<template v-else>
<div class="w-full space-y-2">
<label
for="login-username"
class="block text-sm font-medium text-default"
>
用户名
</label>
<UInput
id="login-username"
v-model="username"
placeholder="用户名"
autocomplete="username"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<div class="w-full space-y-2">
<label
for="login-password"
class="block text-sm font-medium text-default"
>
密码
</label>
<UInput
id="login-password"
v-model="password"
type="password"
placeholder="密码"
autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
@keyup.enter="submit"
/>
</div>
</template>
<UButton
type="submit"
label="登录"
icon="i-lucide-log-in"
size="lg"
block
class="w-full"
:loading="loading"
:disabled="!canSubmit"
/>
<p
v-if="allowRegistration"
class="text-center text-sm text-muted"
>
没有账号?
<NuxtLink
to="/register"
class="text-primary hover:underline"
>
注册
</NuxtLink>
</p>
</form>
<p class="mt-6 text-center text-xs leading-relaxed text-dimmed">
未登录时无法访问上传和管理功能
</p>
</div>
</div>
</template>
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png'
const route = useRoute()
const colorMode = useColorMode()
const { logout, user, isAdmin } = useAuth()
const navItems = computed(() => {
const items = [
{ label: 'API', to: '/api', icon: 'i-lucide-code-2' },
{ label: '统计', to: '/stats', icon: 'i-lucide-chart-column' }
]
if (isAdmin.value) {
items.push({ label: '设置', to: '/settings', icon: 'i-lucide-settings' })
}
return items
})
const themeIcon = computed(() => {
if (colorMode.preference === 'system') return 'i-lucide-monitor'
return colorMode.preference === 'dark' ? 'i-lucide-moon' : 'i-lucide-sun'
})
const themeItems = computed<DropdownMenuItem[][]>(() => [[
{
label: '浅色',
icon: 'i-lucide-sun',
type: 'checkbox',
checked: colorMode.preference === 'light',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'light'
}
},
{
label: '深色',
icon: 'i-lucide-moon',
type: 'checkbox',
checked: colorMode.preference === 'dark',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'dark'
}
},
{
label: '跟随系统',
icon: 'i-lucide-monitor',
type: 'checkbox',
checked: colorMode.preference === 'system',
onUpdateChecked(checked: boolean) {
if (checked) colorMode.preference = 'system'
}
}
]])
const userMenuItems = computed<DropdownMenuItem[][]>(() => {
const items: DropdownMenuItem[] = [
{
label: '修改密码',
icon: 'i-lucide-key-round',
to: '/account/password'
}
]
if (isAdmin.value) {
items.push({
label: '系统设置',
icon: 'i-lucide-settings',
to: '/settings'
})
}
items.push({
label: '退出登录',
icon: 'i-lucide-log-out',
onSelect() {
void handleLogout()
}
})
return [items]
})
function isNavActive(to: string) {
return route.path.startsWith(to)
}
async function handleLogout() {
await logout()
}
</script>
<template>
<div class="flex min-h-screen flex-col">
<header class="sticky top-0 z-40 border-b border-default bg-default/80 backdrop-blur">
<div class="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-2.5 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<NuxtLink
to="/"
title="返回上传"
class="group flex shrink-0 items-center gap-2.5 transition-opacity hover:opacity-90 sm:gap-3"
>
<img
:src="logoLight"
alt="PicHost"
class="h-10 w-auto shrink-0 object-contain sm:h-11 dark:hidden"
decoding="async"
draggable="false"
>
<img
:src="logoDark"
alt="PicHost"
class="hidden h-10 w-auto shrink-0 object-contain sm:h-11 dark:block"
decoding="async"
draggable="false"
>
<div class="min-w-0 leading-tight">
<p class="text-[15px] font-bold tracking-tight sm:text-base">
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</p>
<p class="mt-0.5 text-[11px] text-muted sm:text-xs">
个人轻量图床
</p>
</div>
</NuxtLink>
<div class="flex flex-wrap items-center justify-end gap-1 sm:gap-2">
<nav class="flex flex-wrap items-center gap-1">
<UButton
v-for="item in navItems"
:key="item.to"
:to="item.to"
size="sm"
:variant="isNavActive(item.to) ? 'soft' : 'ghost'"
:color="isNavActive(item.to) ? 'primary' : 'neutral'"
:icon="item.icon"
:label="item.label"
/>
</nav>
<div class="mx-1 hidden h-5 w-px bg-muted sm:block" />
<ClientOnly>
<UDropdownMenu
v-if="user"
:items="userMenuItems"
>
<UButton
variant="ghost"
color="neutral"
size="sm"
class="max-w-[10rem] gap-1.5"
>
<UIcon
name="i-lucide-user"
class="size-4 shrink-0"
/>
<span class="truncate">{{ user.username }}</span>
<UIcon
name="i-lucide-chevron-down"
class="size-3.5 shrink-0 opacity-60"
/>
</UButton>
</UDropdownMenu>
<UDropdownMenu :items="themeItems">
<UButton
:icon="themeIcon"
variant="ghost"
color="neutral"
size="sm"
aria-label="切换主题"
/>
</UDropdownMenu>
<template #fallback>
<div class="size-8" />
</template>
</ClientOnly>
</div>
</div>
</header>
<main class="mx-auto w-full max-w-7xl flex-1 space-y-6 p-4 sm:p-6">
<slot />
</main>
<footer class="px-4 py-6 text-center text-xs text-muted">
Copyright © 2026 O96u
·
<a
href="https://github.com/O96u/PicHost"
target="_blank"
rel="noopener noreferrer"
class="hover:text-highlighted"
>
GitHub
</a>
</footer>
</div>
</template>
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
const props = defineProps<{
label?: string
value: string
iconOnly?: boolean
icon?: string
block?: boolean
successTitle?: string
}>()
const emit = defineEmits<{
copied: []
}>()
const toast = useToast()
const copying = ref(false)
function copyWithFallback(text: string): boolean {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '0'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
textarea.setSelectionRange(0, text.length)
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
} finally {
document.body.removeChild(textarea)
}
return ok
}
function notifySuccess() {
toast.add({
title: props.successTitle ?? (props.label ? `已复制 ${props.label}` : '已复制到剪贴板'),
color: 'success'
})
emit('copied')
}
async function copy() {
if (copying.value || !props.value) return
copying.value = true
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(props.value)
notifySuccess()
return
} catch {
// 内网 http://IP 等非安全上下文会到这里,继续走回退
}
}
if (copyWithFallback(props.value)) {
notifySuccess()
} else {
toast.add({ title: '复制失败,请手动选中链接复制', color: 'error' })
}
} finally {
copying.value = false
}
}
</script>
<template>
<UButton
size="xs"
variant="soft"
:loading="copying"
:disabled="!value"
:label="iconOnly ? undefined : label"
:icon="icon"
:class="block ? 'flex-1 justify-center' : undefined"
:aria-label="iconOnly ? label : undefined"
:title="iconOnly ? label : undefined"
@click="copy"
/>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
defineProps<{
open: boolean
count: number
loading?: boolean
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
'confirm': []
}>()
function close() {
emit('update:open', false)
}
function confirm() {
emit('confirm')
}
</script>
<template>
<UModal
:open="open"
title="确认删除"
:description="`确定要删除选中的 ${count} 张图片吗?此操作不可恢复。`"
@update:open="emit('update:open', $event)"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
label="取消"
color="neutral"
variant="outline"
@click="close"
/>
<UButton
label="确认删除"
color="error"
:loading="loading"
@click="confirm"
/>
</div>
</template>
</UModal>
</template>
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
type CopyFormat = 'url' | 'markdown' | 'html'
const props = withDefaults(defineProps<{
image: ImageItem
selected: boolean
deleting?: boolean
selectable?: boolean
showKey?: boolean
}>(), {
selectable: true,
showKey: true
})
const emit = defineEmits<{
'update:selected': [value: boolean]
'delete': []
}>()
const { formatFileSize } = useFileSize()
const imageError = ref(false)
const copyFormat = ref<CopyFormat>('url')
const copyFormatItems = [
{ label: '直链', value: 'url' as const },
{ label: 'Markdown', value: 'markdown' as const },
{ label: 'HTML', value: 'html' as const }
]
const uploadedLabel = computed(() => {
try {
return new Date(props.image.uploadedAt).toLocaleString('zh-CN', { hour12: false })
} catch {
return props.image.uploadedAt
}
})
const previewValue = computed(() => {
switch (copyFormat.value) {
case 'markdown':
return props.image.markdown
case 'html':
return props.image.html
default:
return props.image.url
}
})
const copySuccessTitle = computed(() => {
switch (copyFormat.value) {
case 'markdown':
return '已复制 Markdown'
case 'html':
return '已复制 HTML'
default:
return '已复制直链'
}
})
function toggleSelected(value: boolean | 'indeterminate') {
emit('update:selected', value === true)
}
</script>
<template>
<div class="flex flex-col overflow-hidden rounded-xl border border-default bg-elevated shadow-sm">
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
<img
v-if="!imageError"
:src="image.url"
:alt="image.originalName"
loading="lazy"
class="absolute inset-0 size-full object-cover object-center"
@error="imageError = true"
>
<div
v-else
class="flex h-full items-center justify-center text-sm text-muted"
>
预览失败
</div>
<div
v-if="selectable"
class="absolute top-2 left-2"
>
<UCheckbox
:model-value="selected"
@update:model-value="toggleSelected"
/>
</div>
<div class="absolute top-2 right-2">
<UButton
size="xs"
color="error"
variant="solid"
icon="i-lucide-trash-2"
aria-label="删除"
:loading="deleting"
@click="emit('delete')"
/>
</div>
<div
v-if="image.owner"
class="absolute bottom-2 left-2 max-w-[calc(100%-1rem)]"
>
<span
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/65 px-2 py-0.5 text-xs font-medium text-white shadow-sm backdrop-blur-sm"
:title="`上传者:${image.owner.username}`"
>
<UIcon
name="i-lucide-user"
class="size-3 shrink-0"
/>
<span class="truncate">{{ image.owner.username }}</span>
</span>
</div>
</div>
<div class="flex flex-1 flex-col gap-2 p-3">
<p
class="truncate text-sm font-medium"
:title="image.originalName"
>
{{ image.originalName }}
</p>
<p class="text-xs text-muted">
{{ uploadedLabel }} · {{ formatFileSize(image.size) }}
</p>
<div class="flex gap-1 rounded-lg border border-default p-0.5">
<UButton
v-for="item in copyFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="() => { copyFormat = item.value }"
/>
</div>
<div class="flex items-center gap-1">
<UInput
:model-value="previewValue"
readonly
size="xs"
class="min-w-0 flex-1 font-mono text-xs"
/>
<CopyButton
icon="i-lucide-copy"
label="复制"
:value="previewValue"
:success-title="copySuccessTitle"
/>
</div>
<p
v-if="showKey"
class="truncate text-xs text-dimmed"
:title="image.key"
>
{{ image.key }}
</p>
</div>
</div>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
const props = withDefaults(defineProps<{
items: ImageItem[]
selectedKeys: Set<string>
deletingKeys: Set<string>
selectable?: boolean
showKey?: boolean
emptyText?: string
}>(), {
selectable: true,
showKey: true,
emptyText: '暂无图片'
})
const emit = defineEmits<{
'update:selectedKeys': [value: Set<string>]
'delete': [key: string]
}>()
function updateSelection(key: string, selected: boolean) {
const next = new Set(props.selectedKeys)
if (selected) next.add(key)
else next.delete(key)
emit('update:selectedKeys', next)
}
</script>
<template>
<div
v-if="items.length"
class="grid grid-cols-1 items-start gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<ImageCard
v-for="image in items"
:key="image.key"
:image="image"
:selected="selectedKeys.has(image.key)"
:deleting="deletingKeys.has(image.key)"
:selectable="selectable"
:show-key="showKey"
@update:selected="updateSelection(image.key, $event)"
@delete="emit('delete', image.key)"
/>
</div>
<div
v-else
class="rounded-xl border border-dashed border-default py-16 text-center text-sm text-muted"
>
{{ emptyText }}
</div>
</template>
+140
View File
@@ -0,0 +1,140 @@
<script setup lang="ts">
const props = defineProps<{
disabled?: boolean
}>()
const emit = defineEmits<{
'upload': [files: File[]]
'open-settings': []
}>()
const fileInput = ref<HTMLInputElement | null>(null)
const dragging = ref(false)
const hovering = ref(false)
const { bindPaste } = useClipboardImage(ref(null))
const isActive = computed(() => dragging.value || hovering.value)
bindPaste((files) => {
if (!props.disabled) emit('upload', files)
})
function openFilePicker() {
if (props.disabled) return
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files ? Array.from(input.files) : []
if (files.length) emit('upload', files)
input.value = ''
}
function onDrop(event: DragEvent) {
event.preventDefault()
dragging.value = false
if (props.disabled) return
const files = Array.from(event.dataTransfer?.files ?? [])
.filter(file => file.type.startsWith('image/'))
if (files.length) emit('upload', files)
}
function onDragOver(event: DragEvent) {
event.preventDefault()
if (!props.disabled) dragging.value = true
}
function onDragLeave() {
dragging.value = false
}
function onMouseEnter() {
if (!props.disabled) hovering.value = true
}
function onMouseLeave() {
hovering.value = false
}
function openSettings() {
if (props.disabled) return
emit('open-settings')
}
</script>
<template>
<div
class="upload-card-surface group/upload relative text-center transition-all duration-300 ease-out"
:class="isActive
? 'scale-[1.01] border-primary bg-primary/5 shadow-md shadow-primary/10'
: 'hover:border-primary/50 hover:bg-primary/[0.04] hover:shadow-sm'"
@drop="onDrop"
@dragover="onDragOver"
@dragleave="onDragLeave"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,image/x-icon,.svg,.ico"
multiple
class="hidden"
:disabled="disabled"
@change="onFileChange"
>
<div class="absolute top-3 right-3 z-10">
<button
type="button"
class="upload-settings-btn inline-flex size-8 items-center justify-center rounded-md text-muted transition-colors hover:bg-elevated hover:text-highlighted disabled:pointer-events-none disabled:opacity-40"
aria-label="上传设置"
:disabled="disabled"
@click="openSettings"
>
<UIcon
name="i-lucide-settings"
class="upload-settings-icon size-4"
/>
</button>
</div>
<div class="mx-auto max-w-lg space-y-4">
<div
class="mx-auto flex size-16 items-center justify-center rounded-2xl bg-primary/10 transition-transform duration-300 ease-out"
:class="isActive ? 'scale-110' : 'group-hover/upload:scale-105'"
>
<UIcon
name="i-lucide-cloud-upload"
class="size-8 text-primary transition-transform duration-300"
:class="isActive ? '-translate-y-0.5' : ''"
/>
</div>
<div class="space-y-2">
<p class="text-xl font-semibold tracking-tight">
拖拽图片到此处上传
</p>
<p class="text-sm text-muted">
或点击选择文件,按
<kbd class="mx-1 rounded border border-default bg-muted/50 px-1.5 py-0.5 font-mono text-xs">Ctrl</kbd>
+
<kbd class="mx-1 rounded border border-default bg-muted/50 px-1.5 py-0.5 font-mono text-xs">V</kbd>
粘贴图片
</p>
</div>
<p class="text-xs text-dimmed">
支持 JPEG / PNG / WebP / GIF / SVG / ICO,单张最大 10 MB,每次最多 10 张
</p>
<UButton
label="选择图片"
icon="i-lucide-upload"
size="lg"
:disabled="disabled"
@click="openFilePicker"
/>
</div>
</div>
</template>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
const props = defineProps<{
page: number
totalPages: number
total: number
unit?: string
loading?: boolean
}>()
const emit = defineEmits<{
'update:page': [page: number]
}>()
const unitLabel = computed(() => props.unit ?? '条')
function goTo(target: number) {
if (target < 1 || target > props.totalPages || target === props.page || props.loading) {
return
}
emit('update:page', target)
}
</script>
<template>
<div
v-if="total > 0"
class="flex flex-col items-center gap-2 border-t border-default pt-4 sm:flex-row sm:justify-between"
>
<p
v-if="totalPages > 1"
class="text-sm text-muted"
>
第 {{ page }} / {{ totalPages }} 页 · 共 {{ total }} {{ unitLabel }}
</p>
<p
v-else
class="text-sm text-muted"
>
共 {{ total }} {{ unitLabel }}
</p>
<div
v-if="totalPages > 1"
class="flex items-center gap-1"
>
<UButton
icon="i-lucide-chevrons-left"
variant="outline"
size="sm"
aria-label="第一页"
:disabled="page <= 1 || loading"
@click="goTo(1)"
/>
<UButton
icon="i-lucide-chevron-left"
variant="outline"
size="sm"
aria-label="上一页"
:disabled="page <= 1 || loading"
@click="goTo(page - 1)"
/>
<UButton
icon="i-lucide-chevron-right"
variant="outline"
size="sm"
aria-label="下一页"
:disabled="page >= totalPages || loading"
@click="goTo(page + 1)"
/>
<UButton
icon="i-lucide-chevrons-right"
variant="outline"
size="sm"
aria-label="最后一页"
:disabled="page >= totalPages || loading"
@click="goTo(totalPages)"
/>
</div>
</div>
</template>
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { analyzePassword, PASSWORD_MIN_LENGTH } from '~/utils/password-strength'
const props = defineProps<{
password: string
}>()
const analysis = computed(() => analyzePassword(props.password))
const show = computed(() => props.password.length > 0)
const labelColor = computed(() => {
switch (analysis.value.label) {
case '强':
return 'text-success'
case '中':
return 'text-warning'
default:
return 'text-error'
}
})
const barColor = computed(() => {
switch (analysis.value.label) {
case '强':
return 'bg-success'
case '中':
return 'bg-warning'
default:
return 'bg-error/80'
}
})
const rules = computed(() => [
{ key: 'minLength', label: `至少 ${PASSWORD_MIN_LENGTH} 个字符` },
{ key: 'mixedCase', label: '包含大小写字母' },
{ key: 'hasNumber', label: '包含数字' },
{ key: 'hasSpecial', label: '包含特殊字符' }
] as const)
</script>
<template>
<div
v-if="show"
class="space-y-3 rounded-lg border border-default bg-muted/20 px-3 py-3"
>
<div class="flex items-center justify-between text-sm">
<span class="text-muted">密码强度</span>
<span
class="font-medium"
:class="labelColor"
>
{{ analysis.label }}
</span>
</div>
<div class="flex gap-1.5">
<span
v-for="index in 4"
:key="index"
class="h-1.5 flex-1 rounded-full transition-colors"
:class="index <= analysis.score ? barColor : 'bg-default'"
/>
</div>
<ul class="flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
<li
v-for="rule in rules"
:key="rule.key"
class="flex items-center gap-1.5 transition-colors"
:class="analysis.criteria[rule.key] ? 'text-success' : 'text-muted'"
>
<UIcon
:name="analysis.criteria[rule.key] ? 'i-lucide-check' : 'i-lucide-circle'"
class="size-3.5 shrink-0"
/>
<span>{{ rule.label }}</span>
</li>
</ul>
</div>
</template>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
export interface DonutSegment {
label: string
value: number
color: string
}
const props = defineProps<{
segments: DonutSegment[]
size?: number
}>()
const total = computed(() =>
props.segments.reduce((sum, item) => sum + item.value, 0)
)
const gradient = computed(() => {
if (!total.value) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
let offset = 0
const stops = props.segments
.filter(item => item.value > 0)
.map((item) => {
const start = offset
const percent = (item.value / total.value) * 100
offset += percent
return `${item.color} ${start}% ${offset}%`
})
if (!stops.length) {
return 'conic-gradient(var(--ui-bg-muted) 0% 100%)'
}
return `conic-gradient(${stops.join(', ')})`
})
function percent(value: number) {
if (!total.value) return 0
return Math.round((value / total.value) * 100)
}
function formatCount(value: number) {
if (value >= 100000000) {
return `${(value / 100000000).toFixed(value >= 1000000000 ? 0 : 1)}亿`
}
if (value >= 10000) {
return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
}
return String(value)
}
const centerText = computed(() => formatCount(total.value))
const centerTextClass = computed(() => {
const raw = String(total.value)
if (raw.length >= 7) return 'text-[9px] font-semibold leading-none'
if (raw.length >= 5) return 'text-xs font-semibold leading-none'
if (raw.length >= 4) return 'text-sm font-semibold leading-none'
return 'text-lg font-semibold leading-none'
})
</script>
<template>
<div class="flex items-center gap-4">
<div
class="relative shrink-0 rounded-full"
:style="{
width: `${size ?? 88}px`,
height: `${size ?? 88}px`,
background: gradient
}"
>
<div
class="absolute inset-[20%] flex flex-col items-center justify-center rounded-full bg-elevated px-1 text-center"
>
<p
class="max-w-full truncate tabular-nums"
:class="centerTextClass"
:title="String(total)"
>
{{ centerText }}
</p>
<p class="mt-0.5 text-[10px] text-muted">
次
</p>
</div>
</div>
<ul class="min-w-0 flex-1 space-y-1.5">
<li
v-for="item in segments"
:key="item.label"
class="flex items-center gap-2 text-xs"
>
<span
class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="min-w-0 flex-1 truncate">{{ item.label }}</span>
<span
class="shrink-0 tabular-nums text-muted"
:title="`${item.value}`"
>
{{ formatCount(item.value) }} ({{ percent(item.value) }}%)
</span>
</li>
</ul>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
defineProps<{
disabled?: boolean
}>()
const emit = defineEmits<{
upload: [files: File[]]
}>()
const flipped = ref(false)
function onOpenSettings() {
flipped.value = true
}
function onBack() {
flipped.value = false
}
</script>
<template>
<div
class="upload-card-flip"
:class="{ 'is-flipped': flipped }"
>
<div class="upload-card-flip-inner">
<div class="upload-card-face upload-card-face--front">
<ImageUploader
:disabled="disabled"
@upload="emit('upload', $event)"
@open-settings="onOpenSettings"
/>
</div>
<div class="upload-card-face upload-card-face--back">
<UploadPreferencesPanel @back="onBack" />
</div>
</div>
</div>
</template>
+221
View File
@@ -0,0 +1,221 @@
<script setup lang="ts">
withDefaults(
defineProps<{
embedded?: boolean
}>(),
{
embedded: false
}
)
const emit = defineEmits<{
back: []
}>()
const {
compressEnabled,
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
loadPreferences
} = useUploadPreferences()
const {
userSettings,
enabled: userAutoDeleteEnabled,
daysDraft: userAutoDeleteDaysDraft,
load: loadUserSettings
} = useUserAutoDeleteSettings()
const copyFormatItems = [
{ label: '直链', value: 'url' as const },
{ label: 'Markdown', value: 'markdown' as const },
{ label: 'HTML', value: 'html' as const }
]
const clientQualityLevel = computed(() => {
if (clientWebpQuality.value >= 85) return '高质量'
if (clientWebpQuality.value >= 60) return '中等质量'
return '有损压缩'
})
function setCopyFormat(value: 'url' | 'markdown' | 'html') {
copyFormat.value = value
}
function onClientQualityInput(event: Event) {
const value = Number((event.target as HTMLInputElement).value)
clientWebpQuality.value = Number.isFinite(value) ? value : 80
}
onMounted(() => {
loadPreferences()
void loadUserSettings()
})
</script>
<template>
<div
class="flex flex-col overflow-hidden"
:class="
embedded ? '' : 'upload-card-surface upload-card-surface--panel h-full'
"
>
<div
class="flex items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6"
>
<div class="flex min-w-0 items-start gap-2">
<UIcon
name="i-lucide-settings-2"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div class="min-w-0">
<h2 class="text-base font-semibold">
上传偏好
</h2>
<p class="mt-0.5 text-xs text-muted">
浏览器本地与账号设置,仅影响您的上传体验
</p>
</div>
</div>
<UButton
v-if="!embedded"
label="返回"
icon="i-lucide-arrow-left"
variant="ghost"
color="neutral"
size="sm"
class="shrink-0"
aria-label="返回上传"
@click="emit('back')"
/>
</div>
<div
class="p-5 sm:p-6"
:class="embedded ? '' : 'flex flex-1 flex-col justify-center'"
>
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
<div class="space-y-4">
<h3 class="text-sm font-semibold">
图片处理
</h3>
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="compressEnabled"
class="mt-0.5"
/>
<span>
<span class="block text-sm">客户端预压缩</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
上传前在浏览器端压缩(服务端仍会转 WebP)
</span>
</span>
</label>
<div
v-if="userSettings"
class="border-t border-default pt-4"
>
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="userAutoDeleteEnabled"
class="mt-0.5"
/>
<span class="min-w-0 flex-1">
<span
class="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm"
>
<span>自动删除超过</span>
<UInput
v-model.number="userAutoDeleteDaysDraft"
type="number"
min="1"
max="3650"
size="xs"
class="w-14 tabular-nums"
:class="
userAutoDeleteEnabled
? ''
: 'pointer-events-none opacity-40'
"
/>
<span>天的图片</span>
</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
仅清理开启后上传的图片,不影响此前已存的图片
</span>
</span>
</label>
</div>
<div
v-else
class="flex justify-center border-t border-default py-4"
>
<UIcon
name="i-lucide-loader-circle"
class="size-5 animate-spin text-muted"
/>
</div>
</div>
<div class="space-y-4">
<h3 class="text-sm font-semibold">
WebP 压缩质量
</h3>
<p class="text-xs leading-relaxed text-muted">
客户端预压缩质量(1–100),需开启「客户端预压缩」
</p>
<div
class="flex items-center justify-between gap-2 text-sm text-muted"
>
<span>当前:{{ clientWebpQuality }}</span>
<span>{{ clientQualityLevel }}</span>
</div>
<input
type="range"
min="1"
max="100"
:value="clientWebpQuality"
class="h-2 w-full cursor-pointer appearance-none rounded-full bg-default accent-primary"
@input="onClientQualityInput"
>
</div>
<div class="space-y-4">
<h3 class="text-sm font-semibold">
便捷功能
</h3>
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="autoCopyMarkdown"
class="mt-0.5"
/>
<span>
<span class="block text-sm">上传成功自动复制链接</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
按所选格式复制到剪贴板
</span>
</span>
</label>
<div
class="ml-7 flex gap-1 rounded-lg border border-default p-0.5"
:class="autoCopyMarkdown ? '' : 'pointer-events-none opacity-40'"
>
<UButton
v-for="item in copyFormatItems"
:key="item.value"
size="xs"
class="flex-1 justify-center"
:variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label"
@click="setCopyFormat(item.value)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { UploadProgressItem } from '~/types/image'
defineProps<{
items: UploadProgressItem[]
}>()
</script>
<template>
<div
v-if="items.length"
class="space-y-2 rounded-lg border border-default p-4"
>
<p class="text-sm font-medium text-muted">
上传进度
</p>
<div
v-for="item in items"
:key="item.name"
class="space-y-1"
>
<div class="flex items-center justify-between gap-2 text-sm">
<span class="truncate">{{ item.name }}</span>
<UBadge
:color="item.status === 'success' ? 'success' : item.status === 'error' ? 'error' : 'primary'"
variant="subtle"
size="xs"
>
{{ item.status === 'pending' ? '等待' : item.status === 'uploading' ? '上传中' : item.status === 'success' ? '成功' : '失败' }}
</UBadge>
</div>
<UProgress
:model-value="item.progress"
size="xs"
/>
<p
v-if="item.error"
class="text-xs text-error"
>
{{ item.error }}
</p>
</div>
</div>
</template>
+299
View File
@@ -0,0 +1,299 @@
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
export type UserRole = 'admin' | 'user'
export interface AuthUser {
id: number
username: string
role: UserRole
}
export interface AuthStatusResponse {
initialized: boolean
allowRegistration: boolean
legacyMode: boolean
needsMigration: boolean
user: AuthUser | null
}
export function isUnauthorizedError(error: unknown): boolean {
return typeof error === 'object'
&& error !== null
&& 'statusCode' in error
&& (error as { statusCode: number }).statusCode === 401
}
export const AUTH_USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
export const AUTH_MIN_PASSWORD_LENGTH = 8
export function validateAuthInput(username: string, password: string): string | null {
const trimmed = username.trim()
if (!AUTH_USERNAME_PATTERN.test(trimmed)) {
return '用户名需为 3–32 位字母、数字或下划线'
}
if (password.length < AUTH_MIN_PASSWORD_LENGTH) {
return '密码至少 8 位'
}
return null
}
export function getFetchErrorMessage(error: unknown, fallback: string): string {
if (!error || typeof error !== 'object') return fallback
const e = error as {
data?: {
message?: string
error?: { message?: string }
data?: { error?: { message?: string } }
}
statusMessage?: string
message?: string
}
const nested = e.data?.data?.error?.message
?? e.data?.error?.message
?? e.data?.message
if (nested) return nested
if (e.statusMessage && e.statusMessage !== 'Bad Request') {
return e.statusMessage
}
return fallback
}
export type AuthActionResult = { ok: true } | { ok: false, error: string }
export function useAuth() {
const status = useState<AuthStatus>('auth-status', () => 'checking')
const user = useState<AuthUser | null>('auth-user', () => null)
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
const isChecking = computed(() => status.value === 'checking')
const isAuthenticated = computed(() => status.value === 'authenticated')
const isAdmin = computed(() => user.value?.role === 'admin')
async function fetchStatus(): Promise<AuthStatusResponse> {
const data = await $fetch<AuthStatusResponse>('/api/auth/status', {
credentials: 'include'
})
authStatus.value = data
return data
}
async function checkSession() {
status.value = 'checking'
try {
const data = await fetchStatus()
if (data.user) {
user.value = data.user
status.value = 'authenticated'
return true
}
user.value = null
status.value = 'unauthenticated'
return false
} catch {
user.value = null
status.value = 'unauthenticated'
return false
}
}
function markUnauthenticated() {
status.value = 'unauthenticated'
user.value = null
}
async function login(
credentials: { username: string, password: string }
| { secret: string }
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
try {
const result = await $fetch<{
success: boolean
needsMigration?: boolean
user?: AuthUser
}>('/api/auth/login', {
method: 'POST',
credentials: 'include',
body: credentials
})
if (result.needsMigration) {
return { ok: true, needsMigration: true }
}
if (result.user) {
user.value = result.user
}
status.value = 'authenticated'
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '登录失败,请检查用户名和密码')
}
}
}
async function register(
username: string,
password: string
): Promise<AuthActionResult> {
const validationError = validateAuthInput(username, password)
if (validationError) {
return { ok: false, error: validationError }
}
try {
const result = await $fetch<{ success: boolean, user: AuthUser }>(
'/api/auth/register',
{
method: 'POST',
credentials: 'include',
body: { username: username.trim(), password }
}
)
user.value = result.user
status.value = 'authenticated'
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '注册失败,请检查输入')
}
}
}
async function setup(input: {
username: string
password: string
allowRegistration: boolean
}): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password)
if (validationError) {
return { ok: false, error: validationError }
}
try {
const result = await $fetch<{ success: boolean, user: AuthUser }>(
'/api/setup',
{
method: 'POST',
credentials: 'include',
body: {
username: input.username.trim(),
password: input.password,
allowRegistration: input.allowRegistration
}
}
)
user.value = result.user
status.value = 'authenticated'
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '初始化失败,请检查输入')
}
}
}
async function migrate(input: {
username: string
password: string
allowRegistration: boolean
}): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password)
if (validationError) {
return { ok: false, error: validationError }
}
try {
const result = await $fetch<{ success: boolean, user: AuthUser }>(
'/api/auth/migrate',
{
method: 'POST',
credentials: 'include',
body: {
username: input.username.trim(),
password: input.password,
allowRegistration: input.allowRegistration
}
}
)
user.value = result.user
status.value = 'authenticated'
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '迁移失败,请检查输入')
}
}
}
async function changePassword(
currentPassword: string,
newPassword: string
): Promise<AuthActionResult> {
if (newPassword.length < AUTH_MIN_PASSWORD_LENGTH) {
return { ok: false, error: '新密码至少 8 位' }
}
try {
await $fetch('/api/auth/password', {
method: 'PATCH',
credentials: 'include',
body: { currentPassword, newPassword }
})
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '修改密码失败')
}
}
}
async function logout() {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
} catch {
// 忽略登出失败
} finally {
status.value = 'unauthenticated'
user.value = null
authStatus.value = null
}
}
function handleAuthError(error: unknown) {
if (isUnauthorizedError(error)) {
markUnauthenticated()
}
}
return {
status,
user,
authStatus,
isChecking,
isAuthenticated,
isAdmin,
checkSession,
fetchStatus,
markUnauthenticated,
login,
register,
setup,
migrate,
changePassword,
logout,
handleAuthError,
getFetchErrorMessage
}
}
+108
View File
@@ -0,0 +1,108 @@
import type { Ref } from 'vue'
const MAX_WIDTH = 2560
const QUALITY = 0.85
export interface CompressOptions {
enabled: boolean
maxWidth?: number
quality?: number
}
function shouldCompress(file: File): boolean {
return file.type === 'image/jpeg' || file.type === 'image/png'
}
async function compressImage(
file: File,
maxWidth: number,
quality: number
): Promise<File> {
const bitmap = await createImageBitmap(file)
const scale = Math.min(1, maxWidth / bitmap.width)
const width = Math.round(bitmap.width * scale)
const height = Math.round(bitmap.height * scale)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
bitmap.close()
return file
}
ctx.drawImage(bitmap, 0, 0, width, height)
bitmap.close()
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/webp', quality)
})
if (!blob) return file
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
return new File([blob], `${baseName}.webp`, { type: 'image/webp' })
}
export async function prepareFilesForUpload(
files: File[],
options: CompressOptions
): Promise<File[]> {
if (!options.enabled) return files
const maxWidth = options.maxWidth ?? MAX_WIDTH
const quality = options.quality ?? QUALITY
const prepared: File[] = []
for (const file of files) {
if (!shouldCompress(file)) {
prepared.push(file)
continue
}
try {
prepared.push(await compressImage(file, maxWidth, quality))
} catch {
prepared.push(file)
}
}
return prepared
}
export function useClipboardImage(_target?: Ref<HTMLElement | null>) {
function onPaste(event: ClipboardEvent, onFiles: (files: File[]) => void) {
const items = event.clipboardData?.items
if (!items?.length) return
const files: File[] = []
for (const item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) files.push(file)
}
}
if (files.length) {
event.preventDefault()
onFiles(files)
}
}
function bindPaste(onFiles: (files: File[]) => void) {
const handler = (event: ClipboardEvent) => onPaste(event, onFiles)
onMounted(() => {
window.addEventListener('paste', handler)
})
onUnmounted(() => {
window.removeEventListener('paste', handler)
})
}
return { bindPaste, onPaste }
}
+10
View File
@@ -0,0 +1,10 @@
export function useFileSize() {
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}
return { formatFileSize }
}
+201
View File
@@ -0,0 +1,201 @@
import type {
ImageItem,
ImageListResponse
} from '~/types/image'
const PAGE_SIZE = 12
export function useImageList() {
const items = ref<ImageItem[]>([])
const page = ref(1)
const totalPages = ref(1)
const total = ref(0)
const loading = ref(false)
const folderTotal = ref<number | null>(null)
const loadingTotal = ref(false)
const searchQuery = ref('')
const activeSearch = ref('')
const activeFolder = ref('all')
function folderQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return {}
return { folder }
}
function matchesActiveFolder(key: string) {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`)
}
async function fetchTotal() {
loadingTotal.value = true
try {
const data = await $fetch<{ total: number }>('/api/images/count', {
credentials: 'include',
query: folderQueryParams()
})
folderTotal.value = data.total
} catch {
folderTotal.value = null
} finally {
loadingTotal.value = false
}
}
async function fetchList(targetPage = 1) {
loading.value = true
try {
const response = await $fetch<ImageListResponse>('/api/images', {
credentials: 'include',
query: {
limit: PAGE_SIZE,
page: targetPage,
...folderQueryParams()
}
})
items.value = response.items
page.value = response.page
totalPages.value = response.totalPages
total.value = response.total
} finally {
loading.value = false
}
}
async function fetchSearch(targetPage = 1) {
const q = activeSearch.value.trim()
if (!q) {
await fetchList(1)
return
}
loading.value = true
try {
const response = await $fetch<ImageListResponse>('/api/images/search', {
credentials: 'include',
query: {
q,
limit: PAGE_SIZE,
page: targetPage,
...folderQueryParams()
}
})
items.value = response.items
page.value = response.page
totalPages.value = response.totalPages
total.value = response.total
} finally {
loading.value = false
}
}
async function refreshList() {
activeSearch.value = ''
searchQuery.value = ''
page.value = 1
await Promise.all([fetchList(1), fetchTotal()])
}
async function initializeList(folder = 'all') {
const next = folder.trim() || 'all'
activeFolder.value = next
page.value = 1
activeSearch.value = ''
searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal()])
}
async function setActiveFolder(folder: string) {
const next = folder.trim() || 'all'
if (next === activeFolder.value) return
activeFolder.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function submitSearch() {
const q = searchQuery.value.trim()
activeSearch.value = q
page.value = 1
if (!q) {
await refreshList()
return
}
await fetchSearch(1)
}
async function goToPage(targetPage: number) {
if (targetPage < 1 || targetPage > totalPages.value || loading.value) return
if (activeSearch.value) {
await fetchSearch(targetPage)
} else {
await fetchList(targetPage)
}
}
function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => matchesActiveFolder(item.key))
if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value]
total.value += matched.length
if (folderTotal.value !== null) {
folderTotal.value += matched.length
}
}
function removeItems(keys: string[]) {
const keySet = new Set(keys)
const removed = items.value.filter(item => keySet.has(item.key)).length
items.value = items.value.filter(item => !keySet.has(item.key))
if (removed > 0) {
total.value = Math.max(0, total.value - removed)
}
if (folderTotal.value !== null && removed > 0) {
folderTotal.value = Math.max(0, folderTotal.value - removed)
}
}
const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? '全部' : folder
if (loadingTotal.value && folderTotal.value === null) {
return `${folderLabel} · 统计中…`
}
if (activeSearch.value) {
return `${folderLabel} · 搜索「${activeSearch.value}」· 共 ${total.value} 张`
}
if (folderTotal.value === null) {
return `${folderLabel} · 共 ${total.value} 张`
}
return `${folderLabel} · 共 ${folderTotal.value} 张`
})
return {
items,
page,
totalPages,
total,
loading,
activeFolder,
searchQuery,
activeSearch,
listSummary,
fetchList,
fetchTotal,
refreshList,
initializeList,
setActiveFolder,
submitSearch,
goToPage,
prependItems,
removeItems
}
}
+120
View File
@@ -0,0 +1,120 @@
import type {
UploadProgressItem,
UploadResponse
} from '~/types/image'
import { isUnauthorizedError } from './useAuth'
import { prepareFilesForUpload } from './useClipboardImage'
const MAX_FILES = 10
export function useImageUpload() {
const toast = useToast()
const { compressEnabled, clientWebpQuality } = useUploadPreferences()
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
const lastUploadResult = ref<UploadResponse | null>(null)
async function uploadFiles(
files: File[],
folder = 'images',
options?: { notify?: boolean, includeFolder?: boolean }
) {
if (uploading.value) return
if (!files.length) {
toast.add({ title: '请选择图片', color: 'warning' })
return
}
if (files.length > MAX_FILES) {
toast.add({
title: `每次最多上传 ${MAX_FILES} 张图片`,
color: 'warning'
})
return
}
uploading.value = true
progressItems.value = files.map(file => ({
name: file.name,
status: 'pending',
progress: 0
}))
try {
const prepared = await prepareFilesForUpload(files, {
enabled: compressEnabled.value,
quality: clientWebpQuality.value / 100
})
progressItems.value = prepared.map((file, index) => ({
name: file.name,
status: 'uploading',
progress: Math.round(((index + 1) / prepared.length) * 50)
}))
const formData = new FormData()
if (options?.includeFolder !== false) {
formData.append('folder', folder.trim() || 'images')
}
for (const file of prepared) {
formData.append('files', file, file.name)
}
const response = await $fetch<UploadResponse>('/api/images/upload', {
method: 'POST',
body: formData
})
lastUploadResult.value = response
progressItems.value = prepared.map((file) => {
const failed = response.errors.find(error => error.name === file.name)
return {
name: file.name,
status: failed ? 'error' : 'success',
progress: 100,
error: failed?.error.message
}
})
if (response.items.length && options?.notify !== false) {
toast.add({
title: `成功上传 ${response.items.length} 张图片`,
color: 'success'
})
}
if (response.errors.length) {
toast.add({
title: `${response.errors.length} 张图片上传失败`,
color: 'warning'
})
}
return response
} catch (error: unknown) {
if (isUnauthorizedError(error)) {
throw error
}
const message = error instanceof Error ? error.message : '上传失败'
progressItems.value = progressItems.value.map(item => ({
...item,
status: 'error',
progress: 100,
error: message
}))
toast.add({ title: message, color: 'error' })
throw error
} finally {
uploading.value = false
}
}
return {
uploading,
progressItems,
lastUploadResult,
uploadFiles
}
}
+101
View File
@@ -0,0 +1,101 @@
const STORAGE_KEY = 'pichost.upload-preferences'
export type CopyFormat = 'url' | 'markdown' | 'html'
export interface UploadPreferences {
defaultFolder: string
compressEnabled: boolean
autoCopyMarkdown: boolean
copyFormat: CopyFormat
clientWebpQuality: number
}
const DEFAULTS: UploadPreferences = {
defaultFolder: 'images',
compressEnabled: false,
autoCopyMarkdown: false,
copyFormat: 'markdown',
clientWebpQuality: 80
}
function clampQuality(value: unknown): number {
const num = Number(value)
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
return Math.min(100, Math.max(1, Math.round(num)))
}
function isCopyFormat(value: unknown): value is CopyFormat {
return value === 'url' || value === 'markdown' || value === 'html'
}
function readStored(): UploadPreferences {
if (import.meta.server) return { ...DEFAULTS }
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
return {
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
? parsed.defaultFolder.trim()
: DEFAULTS.defaultFolder,
compressEnabled: parsed.compressEnabled === true,
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
clientWebpQuality: clampQuality(parsed.clientWebpQuality)
}
} catch {
return { ...DEFAULTS }
}
}
function writeStored(prefs: UploadPreferences) {
if (import.meta.server) return
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
}
export function useUploadPreferences() {
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
const loaded = useState('upload-pref-loaded', () => false)
function loadPreferences() {
const stored = readStored()
defaultFolder.value = stored.defaultFolder
compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat
clientWebpQuality.value = stored.clientWebpQuality
loaded.value = true
}
function savePreferences() {
writeStored({
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
compressEnabled: compressEnabled.value,
autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value,
clientWebpQuality: clampQuality(clientWebpQuality.value)
})
}
watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
if (loaded.value) {
savePreferences()
}
})
return {
defaultFolder,
compressEnabled,
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
loaded,
loadPreferences,
savePreferences
}
}
@@ -0,0 +1,81 @@
export function useUserAutoDeleteSettings() {
const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast()
const userSettings = ref<{ autoDeleteDays: number } | null>(null)
const enabled = ref(false)
const daysDraft = ref(30)
const hydrated = ref(false)
const saving = ref(false)
let daysSaveTimer: ReturnType<typeof setTimeout> | null = null
function apply(data: { autoDeleteDays: number }) {
hydrated.value = false
userSettings.value = data
enabled.value = data.autoDeleteDays > 0
daysDraft.value = data.autoDeleteDays > 0 ? data.autoDeleteDays : 30
nextTick(() => {
hydrated.value = true
})
}
async function load() {
try {
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
credentials: 'include'
})
apply(data)
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载设置失败', color: 'error' })
}
}
}
async function save() {
if (!isAuthenticated.value || !userSettings.value) return
const days = enabled.value ? daysDraft.value : 0
if (enabled.value && (days < 1 || days > 3650)) return
saving.value = true
try {
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
method: 'PATCH',
credentials: 'include',
body: { autoDeleteDays: days }
})
apply(data)
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '保存设置失败', color: 'error' })
}
} finally {
saving.value = false
}
}
watch(enabled, () => {
if (!hydrated.value) return
void save()
})
watch(daysDraft, () => {
if (!hydrated.value || !enabled.value) return
if (daysSaveTimer) clearTimeout(daysSaveTimer)
daysSaveTimer = setTimeout(() => {
void save()
}, 500)
})
return {
userSettings,
enabled,
daysDraft,
saving,
load
}
}
+171
View File
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { isPasswordValid } from '~/utils/password-strength'
const router = useRouter()
const {
isChecking,
isAuthenticated,
checkSession,
fetchStatus,
changePassword
} = useAuth()
const toast = useToast()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const canSubmit = computed(() =>
Boolean(currentPassword.value && newPassword.value && confirmPassword.value)
&& newPassword.value === confirmPassword.value
&& isPasswordValid(newPassword.value)
)
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await router.replace('/setup')
return
}
if (!isAuthenticated.value) {
await checkSession()
}
})
async function submit() {
if (!canSubmit.value || loading.value) return
if (newPassword.value !== confirmPassword.value) {
toast.add({ title: '两次输入的新密码不一致', color: 'error' })
return
}
loading.value = true
try {
const result = await changePassword(currentPassword.value, newPassword.value)
if (result.ok) {
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
toast.add({ title: '密码已修改', color: 'success' })
await router.replace('/')
} else {
toast.add({ title: result.error, color: 'error' })
}
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<section class="mx-auto max-w-md overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="border-b border-default px-5 py-4 sm:px-6">
<h1 class="text-lg font-semibold tracking-tight">
修改密码
</h1>
<p class="mt-1 text-sm text-muted">
修改您的登录密码
</p>
</div>
<form
class="space-y-5 p-5 sm:p-6"
@submit.prevent="submit"
>
<div class="space-y-2">
<label
for="current-password"
class="block text-sm font-medium"
>
当前密码
</label>
<UInput
id="current-password"
v-model="currentPassword"
type="password"
autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<div class="space-y-2">
<label
for="new-password"
class="block text-sm font-medium"
>
新密码
</label>
<UInput
id="new-password"
v-model="newPassword"
type="password"
placeholder="设置新密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
<PasswordStrength :password="newPassword" />
</div>
<div class="space-y-2">
<label
for="confirm-new-password"
class="block text-sm font-medium"
>
确认新密码
</label>
<UInput
id="confirm-new-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<div class="flex gap-2 pt-1">
<UButton
type="submit"
label="保存"
icon="i-lucide-save"
:loading="loading"
:disabled="!canSubmit"
/>
<UButton
label="取消"
variant="ghost"
color="neutral"
to="/"
/>
</div>
</form>
</section>
</AppShell>
</div>
</template>
+518
View File
@@ -0,0 +1,518 @@
<script setup lang="ts">
interface SettingsResponse {
apiUploadToken: string
tokenSource: 'env' | 'db' | 'none'
envTokenOverride: boolean
env: {
webpQuality: number
refererConfigured: boolean
imageBaseUrl: string
appVersion: string
}
}
interface ApiDocItem {
index: number
method: 'GET' | 'POST' | 'DELETE'
path: string
title: string
description: string
curl: string
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const settings = ref<SettingsResponse | null>(null)
const loading = ref(false)
const regenerating = ref(false)
const confirmRegenerateOpen = ref(false)
const regenerateResultOpen = ref(false)
const newToken = ref('')
const baseUrl = computed(() => {
if (import.meta.client) {
return window.location.origin
}
return settings.value?.env.imageBaseUrl ?? ''
})
const tokenDisplay = computed(() => settings.value?.apiUploadToken ?? '')
const hasToken = computed(() => tokenDisplay.value.length > 0)
const canRegenerate = computed(() => !settings.value?.envTokenOverride && !loading.value)
const authHeader = computed(() =>
hasToken.value ? tokenDisplay.value : 'YOUR-TOKEN'
)
const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
const apiDocs = computed<ApiDocItem[]>(() => {
const uploadDescription = isAdmin.value
? '上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。'
: '上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。'
const uploadCurl = isAdmin.value
? `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "image=@./demo.png"
# 上传到自定义目录 blog/
curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "folder=blog" \\
-F "image=@./demo.png"`
: `curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "image=@./demo.png"`
const listDescription = isAdmin.value
? '分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。'
: '分页列出您的图库,limit 默认 20、最大 100。'
const listCurl = isAdmin.value
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}
# 按目录筛选
curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
${authHeaderFlag.value}`
: `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}`
return [
{
index: 1,
method: 'POST',
path: '/api/images/upload',
title: '上传图片',
description: uploadDescription,
curl: uploadCurl
},
{
index: 2,
method: 'GET',
path: '/api/images',
title: '获取图片列表',
description: listDescription,
curl: listCurl
},
{
index: 3,
method: 'GET',
path: '/api/images/search',
title: '搜索图片',
description: '按文件名或路径关键词搜索,参数 q 必填。',
curl: `curl "${baseUrl.value}/api/images/search?q=demo&limit=20&page=1" \\
${authHeaderFlag.value}`
},
{
index: 4,
method: 'DELETE',
path: '/api/images',
title: '删除图片',
description: '通过 key 删除单张图片,key 为存储路径(如 images/2026/08/xxx.webp)。',
curl: `curl -X DELETE "${baseUrl.value}/api/images?key=images/2026/08/xxxx.webp" \\
${authHeaderFlag.value}`
},
{
index: 5,
method: 'POST',
path: '/api/images/batch-delete',
title: '批量删除',
description: '一次删除多张图片,请求体为 keys 数组。',
curl: `curl -X POST "${baseUrl.value}/api/images/batch-delete" \\
${authHeaderFlag.value} \\
-H "Content-Type: application/json" \\
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'`
}
]
})
const twikooDoc = computed(() => ({
method: 'POST' as const,
path: '/api/index.php',
title: 'twikoo 兼容上传',
description: '兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。',
curl: `curl -X POST "${baseUrl.value}/api/index.php" \\
-F "token=${authHeader.value}" \\
-F "image=@./demo.png"`
}))
function tokenSourceLabel(source: SettingsResponse['tokenSource']) {
switch (source) {
case 'env':
return '来源:环境变量 API_UPLOAD_TOKEN'
case 'db':
return '来源:后台生成(可重新生成)'
default:
return '来源:未配置'
}
}
function methodBadgeColor(method: ApiDocItem['method']) {
switch (method) {
case 'GET':
return 'info' as const
case 'DELETE':
return 'error' as const
default:
return 'primary' as const
}
}
async function loadSettings() {
loading.value = true
try {
const endpoint = isAdmin.value ? '/api/settings' : '/api/user/api-token'
settings.value = await $fetch<SettingsResponse>(endpoint, {
credentials: 'include'
})
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载 API 设置失败', color: 'error' })
}
} finally {
loading.value = false
}
}
function requestRegenerate() {
if (!canRegenerate.value) return
confirmRegenerateOpen.value = true
}
async function confirmRegenerate() {
confirmRegenerateOpen.value = false
regenerating.value = true
try {
const endpoint = isAdmin.value
? '/api/settings/api-token/regenerate'
: '/api/user/api-token/regenerate'
const response = await $fetch<{ apiUploadToken: string }>(
endpoint,
{ method: 'POST', credentials: 'include' }
)
newToken.value = response.apiUploadToken
regenerateResultOpen.value = true
await loadSettings()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
const status = typeof error === 'object' && error !== null && 'statusCode' in error
? (error as { statusCode: number }).statusCode
: 0
if (status === 409) {
toast.add({
title: '环境变量已配置 Token,无法在后台重新生成',
color: 'warning'
})
} else {
toast.add({ title: '重新生成 Token 失败', color: 'error' })
}
}
} finally {
regenerating.value = false
}
}
async function copyCurl(text: string) {
try {
await navigator.clipboard.writeText(text)
toast.add({ title: '已复制 cURL 示例', color: 'success' })
} catch {
toast.add({ title: '复制失败', color: 'error' })
}
}
function closeConfirmRegenerate() {
confirmRegenerateOpen.value = false
}
function closeRegenerateResult() {
regenerateResultOpen.value = false
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
})
watch(isAuthenticated, async (authed) => {
if (authed) {
await nextTick()
await loadSettings()
} else {
settings.value = null
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-key-round"
class="size-5 text-primary"
/>
<h2 class="text-base font-semibold">
API Token
</h2>
</div>
<div class="space-y-4 p-5 sm:p-6">
<UAlert
v-if="settings?.envTokenOverride"
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
title="Token 由环境变量覆盖"
description="修改 API_UPLOAD_TOKEN 后需重启服务;后台无法重新生成。"
/>
<UAlert
v-else-if="!isAdmin"
color="info"
variant="subtle"
icon="i-lucide-info"
title="个人 API Token"
description="此 Token 仅用于您自己的脚本上传,上传的图片归属您的账号。"
/>
<UAlert
v-else-if="settings && !hasToken"
color="info"
variant="subtle"
icon="i-lucide-info"
title="尚未配置 Token"
description="点击「重新生成」创建 Token,用于下方所有接口的 Auth-Token 鉴权。"
/>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<UInput
:model-value="hasToken ? tokenDisplay : '(未配置)'"
readonly
class="min-w-0 flex-1 font-mono text-xs sm:text-sm"
:loading="loading"
/>
<div class="flex gap-2">
<CopyButton
label="复制"
:value="tokenDisplay"
/>
<UButton
icon="i-lucide-refresh-cw"
label="重新生成"
color="warning"
variant="soft"
:loading="regenerating"
:disabled="!canRegenerate"
@click="requestRegenerate"
/>
</div>
</div>
<p
v-if="settings"
class="text-xs text-muted"
>
{{ tokenSourceLabel(settings.tokenSource) }}
· 请求头:
<code class="font-mono">Auth-Token: &lt;token&gt;</code>
<template v-if="hasToken && !settings.envTokenOverride">
· 重新生成后旧 Token 立即失效
</template>
</p>
</div>
</section>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-center gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-book-open"
class="size-5 text-primary"
/>
<h2 class="text-base font-semibold">
API 文档
</h2>
</div>
<div class="divide-y divide-default">
<article
v-for="doc in apiDocs"
:key="doc.index"
class="space-y-4 p-5 sm:p-6"
>
<div class="flex gap-3">
<span
class="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{{ doc.index }}
</span>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<UBadge
:color="methodBadgeColor(doc.method)"
variant="subtle"
size="sm"
>
{{ doc.method }}
</UBadge>
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
{{ doc.path }}
</code>
<span class="text-sm font-medium">
{{ doc.title }}
</span>
</div>
<p class="text-sm leading-relaxed text-muted">
{{ doc.description }}
</p>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">
cURL 示例
</p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
<UButton
icon="i-lucide-copy"
label="复制"
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(doc.curl)"
/>
<pre class="max-h-80 overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ doc.curl }}</code></pre>
</div>
</div>
</article>
</div>
</section>
<section class="overflow-hidden rounded-2xl border border-dashed border-default bg-muted/20">
<div class="space-y-4 p-5 sm:p-6">
<div class="flex gap-3">
<UIcon
name="i-lucide-message-square"
class="mt-0.5 size-5 shrink-0 text-muted"
/>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<UBadge
color="neutral"
variant="subtle"
size="sm"
>
{{ twikooDoc.method }}
</UBadge>
<code class="rounded-md border border-primary/20 bg-primary/10 px-2 py-0.5 font-mono text-sm font-medium text-primary">
{{ twikooDoc.path }}
</code>
<span class="text-sm font-medium">
{{ twikooDoc.title }}
</span>
</div>
<p class="text-sm leading-relaxed text-muted">
{{ twikooDoc.description }}
</p>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">
cURL 示例
</p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950">
<UButton
icon="i-lucide-copy"
label="复制"
size="xs"
variant="ghost"
color="neutral"
class="absolute top-2 right-2 z-10 text-neutral-300 hover:text-white"
@click="copyCurl(twikooDoc.curl)"
/>
<pre class="overflow-auto p-4 pt-10 text-xs leading-relaxed text-neutral-100"><code class="font-mono">{{ twikooDoc.curl }}</code></pre>
</div>
</div>
</div>
</section>
</AppShell>
<UModal
:open="confirmRegenerateOpen"
title="确认重新生成 Token?"
description="旧 Token 将立即失效,使用旧 Token 的脚本与自动化任务将无法继续调用接口,直到你更新为新 Token。"
@update:open="confirmRegenerateOpen = $event"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
label="取消"
color="neutral"
variant="outline"
@click="closeConfirmRegenerate"
/>
<UButton
label="确认重新生成"
color="warning"
:loading="regenerating"
@click="confirmRegenerate"
/>
</div>
</template>
</UModal>
<UModal
:open="regenerateResultOpen"
title="新 Token 已生成"
description="请立即复制保存。关闭弹窗后仍可在上方输入框复制,但请尽快更新到你的脚本配置中。"
@update:open="regenerateResultOpen = $event"
>
<template #body>
<UInput
:model-value="newToken"
readonly
class="w-full font-mono text-sm"
/>
</template>
<template #footer>
<div class="flex w-full flex-wrap justify-end gap-2">
<CopyButton
label="复制 Token"
:value="newToken"
/>
<UButton
label="我已保存"
@click="closeRegenerateResult"
/>
</div>
</template>
</UModal>
</div>
</template>
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import type { ImageItem } from '~/types/image'
const { uploading, progressItems, uploadFiles } = useImageUpload()
const {
defaultFolder,
autoCopyMarkdown,
copyFormat,
loadPreferences
} = useUploadPreferences()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin }
= useAuth()
const toast = useToast()
const folderOptions = ref<string[]>(['images'])
const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set())
const emptySelection = ref(new Set<string>())
const showProgress = computed(
() =>
uploading.value
|| progressItems.value.some(
item =>
item.status === 'pending'
|| item.status === 'uploading'
|| item.status === 'error'
)
)
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
if (!folderOptions.value.includes(defaultFolder.value)) {
defaultFolder.value = folderOptions.value[0] ?? 'images'
}
}
async function loadPage() {
try {
loadPreferences()
if (isAdmin.value) {
await loadFolders()
}
} catch (error: unknown) {
handleAuthError(error)
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
if (!isAuthenticated.value) {
await checkSession()
}
if (isAuthenticated.value) {
await loadPage()
}
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await loadPage()
} else if (!authed) {
sessionItems.value = []
}
})
function copyFormatLabel() {
switch (copyFormat.value) {
case 'url':
return '直链'
case 'html':
return 'HTML'
default:
return 'Markdown'
}
}
async function copyUploadLink(text: string) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
toast.add({ title: `已复制 ${copyFormatLabel()}`, color: 'success' })
} catch {
toast.add({ title: '复制失败', color: 'error' })
}
}
async function handleUpload(files: File[]) {
if (!isAuthenticated.value) {
toast.add({ title: '请先登录', color: 'warning' })
return
}
try {
const uploadTarget = isAdmin.value
? (defaultFolder.value.trim() || 'images')
: 'images'
const result = await uploadFiles(files, uploadTarget, {
includeFolder: isAdmin.value
})
if (result?.items.length) {
sessionItems.value = [...result.items, ...sessionItems.value]
if (isAdmin.value) {
const folder = uploadTarget
if (!folderOptions.value.includes(folder)) {
folderOptions.value = [...folderOptions.value, folder]
}
}
if (autoCopyMarkdown.value && result.items[0]) {
const first = result.items[0]
const text
= copyFormat.value === 'url'
? first.url
: copyFormat.value === 'html'
? first.html
: first.markdown
if (text) await copyUploadLink(text)
}
}
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleDelete(key: string) {
deletingKeys.value.add(key)
try {
await $fetch('/api/images', {
method: 'DELETE',
query: { key }
})
sessionItems.value = sessionItems.value.filter(item => item.key !== key)
toast.add({ title: '已删除', color: 'success' })
} catch (error: unknown) {
handleAuthError(error)
toast.add({ title: '删除失败', color: 'error' })
} finally {
deletingKeys.value.delete(key)
deletingKeys.value = new Set(deletingKeys.value)
}
}
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<UploadFlipCard
:disabled="uploading"
@upload="handleUpload"
/>
<UploadResult
v-if="showProgress"
:items="progressItems"
/>
<section
v-if="sessionItems.length"
class="space-y-4"
>
<div class="flex items-center justify-between gap-2">
<h2 class="text-lg font-medium">
本次上传
<span class="ml-1 text-sm font-normal text-muted">({{ sessionItems.length }})</span>
</h2>
</div>
<ImageGrid
:items="sessionItems"
:selected-keys="emptySelection"
:deleting-keys="deletingKeys"
:selectable="false"
:show-key="false"
empty-text="暂无本次上传"
@update:selected-keys="() => {}"
@delete="handleDelete"
/>
</section>
</AppShell>
</div>
</template>
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png'
import { isPasswordValid } from '~/utils/password-strength'
const router = useRouter()
const { register, fetchStatus } = useAuth()
const toast = useToast()
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const canSubmit = computed(() =>
Boolean(username.value && password.value && confirmPassword.value)
&& password.value === confirmPassword.value
&& isPasswordValid(password.value)
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
)
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized) {
await router.replace('/setup')
return
}
if (!status.allowRegistration) {
await router.replace('/')
}
})
async function submit() {
if (!canSubmit.value || loading.value) return
if (password.value !== confirmPassword.value) {
toast.add({ title: '两次输入的密码不一致', color: 'error' })
return
}
loading.value = true
try {
const result = await register(username.value.trim(), password.value)
if (result.ok) {
toast.add({ title: '注册成功', color: 'success' })
await router.replace('/')
} else {
toast.add({ title: result.error, color: 'error' })
}
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8">
<div class="mb-8 space-y-3 text-center">
<div class="mx-auto inline-flex items-center justify-center">
<img
:src="logoLight"
alt=""
aria-hidden="true"
class="h-14 w-auto shrink-0 object-contain dark:hidden sm:h-16"
decoding="async"
draggable="false"
>
<img
:src="logoDark"
alt=""
aria-hidden="true"
class="hidden h-14 w-auto shrink-0 object-contain dark:block sm:h-16"
decoding="async"
draggable="false"
>
</div>
<div class="space-y-1">
<h1 class="text-xl font-bold tracking-tight sm:text-2xl">
注册账号
</h1>
<p class="text-sm text-muted">
创建 PicHost 用户账号
</p>
</div>
</div>
<form
class="flex w-full flex-col gap-5"
@submit.prevent="submit"
>
<div class="w-full space-y-2">
<label
for="register-username"
class="block text-sm font-medium text-default"
>
用户名
</label>
<UInput
id="register-username"
v-model="username"
placeholder="3–32 位字母、数字或下划线"
autocomplete="username"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<div class="w-full space-y-2">
<label
for="register-password"
class="block text-sm font-medium text-default"
>
密码
</label>
<UInput
id="register-password"
v-model="password"
type="password"
placeholder="设置登录密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
<PasswordStrength :password="password" />
</div>
<div class="w-full space-y-2">
<label
for="register-confirm"
class="block text-sm font-medium text-default"
>
确认密码
</label>
<UInput
id="register-confirm"
v-model="confirmPassword"
type="password"
placeholder="再次输入密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<UButton
type="submit"
label="注册"
icon="i-lucide-user-plus"
size="lg"
block
class="w-full"
:loading="loading"
:disabled="!canSubmit"
/>
<p class="text-center text-sm text-muted">
已有账号?
<NuxtLink
to="/"
class="text-primary hover:underline"
>
返回登录
</NuxtLink>
</p>
</form>
</div>
</div>
</template>
+359
View File
@@ -0,0 +1,359 @@
<script setup lang="ts">
type SettingSource = 'env' | 'db' | 'none'
type WebpQualitySource = 'env' | 'db' | 'default'
interface SettingsResponse {
apiUploadToken: string
tokenSource: SettingSource
envTokenOverride: boolean
webpQuality: number
webpQualitySource: WebpQualitySource
allowedRefererHosts: string
refererSource: SettingSource
refererEnvFallback: string
imageBaseUrl: string
imageBaseUrlConfigured: string
imageBaseUrlSource: SettingSource
allowRegistration: boolean
appVersion: string
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const { defaultFolder, loadPreferences } = useUploadPreferences()
const toast = useToast()
const settings = ref<SettingsResponse | null>(null)
const folderOptions = ref<string[]>(['images'])
const savingServer = ref(false)
const refererDraft = ref('')
const imageBaseUrlDraft = ref('')
const allowRegistrationDraft = ref(false)
const folderSelectItems = computed(() =>
folderOptions.value.map(folder => ({ label: folder, value: folder }))
)
const hasServerChanges = computed(() => {
if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured
|| allowRegistrationDraft.value !== settings.value.allowRegistration
})
function sourceBadge(source: SettingSource | WebpQualitySource) {
switch (source) {
case 'env':
return { label: '.env', color: 'warning' as const }
case 'db':
return { label: '已保存', color: 'success' as const }
case 'default':
return { label: '默认', color: 'neutral' as const }
default:
return { label: '未设置', color: 'neutral' as const }
}
}
function tokenSourceText(source: SettingSource) {
switch (source) {
case 'env':
return '环境变量'
case 'db':
return '后台生成'
default:
return '未配置'
}
}
function errorStatus(error: unknown): number {
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
return (error as { statusCode: number }).statusCode
}
return 0
}
function applySettings(data: SettingsResponse) {
settings.value = data
refererDraft.value = data.allowedRefererHosts
imageBaseUrlDraft.value = data.imageBaseUrlConfigured
allowRegistrationDraft.value = data.allowRegistration
}
async function patchSettings(body: Record<string, unknown>) {
const data = await $fetch<SettingsResponse>('/api/settings', {
method: 'PATCH',
credentials: 'include',
body
})
applySettings(data)
return data
}
function handlePatchError(error: unknown, fallback: string) {
handleAuthError(error)
if (!isAuthenticated.value) return
if (errorStatus(error) === 409) {
toast.add({ title: '该项已由环境变量覆盖,无法在后台修改', color: 'warning' })
void loadSettings()
return
}
toast.add({ title: fallback, color: 'error' })
}
async function saveServerSettings() {
if (!isAuthenticated.value || !hasServerChanges.value) return
savingServer.value = true
try {
await patchSettings({
allowedRefererHosts: refererDraft.value,
imageBaseUrl: imageBaseUrlDraft.value,
allowRegistration: allowRegistrationDraft.value
})
toast.add({ title: '服务器配置已保存', color: 'success' })
} catch (error: unknown) {
handlePatchError(error, '保存服务器配置失败')
} finally {
savingServer.value = false
}
}
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
if (!folderOptions.value.includes(defaultFolder.value)) {
defaultFolder.value = folderOptions.value[0] ?? 'images'
}
}
async function loadSettings() {
try {
const data = await $fetch<SettingsResponse>('/api/settings', {
credentials: 'include'
})
applySettings(data)
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载设置失败', color: 'error' })
}
}
}
async function loadPage() {
if (!isAdmin.value) {
await navigateTo('/')
return
}
loadPreferences()
await Promise.all([loadSettings(), loadFolders()])
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value && !isAdmin.value) {
await navigateTo('/')
}
})
watch(isAuthenticated, async (authed) => {
if (authed) {
if (!isAdmin.value) {
await navigateTo('/')
return
}
await nextTick()
await loadPage()
} else {
settings.value = null
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else-if="isAdmin">
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<UploadPreferencesPanel embedded />
</section>
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex items-start gap-2 border-b border-default px-5 py-4 sm:px-6">
<UIcon
name="i-lucide-server"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div class="min-w-0">
<h1 class="text-base font-semibold">
系统设置
</h1>
<p class="mt-0.5 text-xs text-muted">
保存后写入数据库;环境变量优先级更高
</p>
</div>
</div>
<div
v-if="settings"
class="p-5 sm:p-6"
>
<div class="grid gap-5 sm:grid-cols-3 sm:gap-6">
<div class="space-y-4">
<h3 class="text-sm font-semibold">
上传与账号
</h3>
<div class="space-y-2">
<label class="text-sm">默认上传目录</label>
<p class="text-xs leading-relaxed text-muted">
保存在本机浏览器,仅影响您的上传目标目录
</p>
<USelect
v-model="defaultFolder"
:items="folderSelectItems"
class="w-full"
/>
</div>
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="allowRegistrationDraft"
class="mt-0.5"
/>
<span>
<span class="block text-sm">开放用户注册</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
允许新用户自行注册(普通用户仅可见自己的图片)
</span>
</span>
</label>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold">
访问控制
</h3>
<UBadge
:color="sourceBadge(settings.refererSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.refererSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
Referer 防盗链,逗号分隔允许引用的域名,留空不限制
</p>
<UTextarea
v-model="refererDraft"
placeholder="example.com, www.example.com"
:rows="4"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold">
链接生成
</h3>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
生成图片链接的 URL 前缀,内网访问建议填公网域名
</p>
<UInput
v-model="imageBaseUrlDraft"
placeholder="https://pic.example.com"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="text-xs text-muted"
>
当前生效:<span class="font-mono break-all">{{ settings.imageBaseUrl }}</span>
</p>
</div>
</div>
</div>
<div
v-else
class="flex justify-center py-12"
>
<UIcon
name="i-lucide-loader-circle"
class="size-6 animate-spin text-muted"
/>
</div>
<div
v-if="settings"
class="flex flex-wrap items-center justify-between gap-3 border-t border-default px-5 py-4 sm:px-6"
>
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
<UIcon
name="i-lucide-key-round"
class="size-3.5 shrink-0"
/>
<span>
API Token · {{ tokenSourceText(settings.tokenSource) }}
<template v-if="hasServerChanges">
· <span class="text-warning">有未保存的更改</span>
</template>
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
label="管理 Token"
icon="i-lucide-external-link"
variant="outline"
color="neutral"
size="sm"
to="/api"
/>
<UButton
label="保存"
icon="i-lucide-save"
size="sm"
:loading="savingServer"
:disabled="!hasServerChanges"
@click="saveServerSettings"
/>
</div>
</div>
</section>
</AppShell>
</div>
</template>
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png'
import { isPasswordValid } from '~/utils/password-strength'
const route = useRoute()
const router = useRouter()
const { setup, migrate, fetchStatus } = useAuth()
const toast = useToast()
const isMigrate = computed(() => route.query.migrate === '1')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const allowRegistration = ref(false)
const loading = ref(false)
const canSubmit = computed(() =>
Boolean(username.value && password.value && confirmPassword.value)
&& password.value === confirmPassword.value
&& isPasswordValid(password.value)
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
)
onMounted(async () => {
const status = await fetchStatus()
if (status.initialized && !status.needsMigration) {
await router.replace('/')
}
})
async function submit() {
if (!canSubmit.value || loading.value) return
if (password.value !== confirmPassword.value) {
toast.add({ title: '两次输入的密码不一致', color: 'error' })
return
}
loading.value = true
try {
const payload = {
username: username.value.trim(),
password: password.value,
allowRegistration: allowRegistration.value
}
const result = isMigrate.value
? await migrate(payload)
: await setup(payload)
if (result.ok) {
toast.add({
title: isMigrate.value ? '账号迁移完成' : '初始化完成',
color: 'success'
})
await router.replace('/')
} else {
toast.add({ title: result.error, color: 'error' })
}
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center bg-muted/30 p-4 sm:p-6">
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8">
<div class="mb-8 space-y-3 text-center">
<div class="mx-auto inline-flex items-center justify-center">
<img
:src="logoLight"
alt=""
aria-hidden="true"
class="h-14 w-auto shrink-0 object-contain dark:hidden sm:h-16"
decoding="async"
draggable="false"
>
<img
:src="logoDark"
alt=""
aria-hidden="true"
class="hidden h-14 w-auto shrink-0 object-contain dark:block sm:h-16"
decoding="async"
draggable="false"
>
</div>
<div class="space-y-1">
<h1 class="text-xl font-bold tracking-tight sm:text-2xl">
<span class="text-highlighted">Pic</span><span class="text-primary">Host</span>
</h1>
<p class="text-sm text-muted">
{{ isMigrate ? '迁移到账号密码登录' : '首次使用,创建管理员账号' }}
</p>
</div>
</div>
<form
class="flex w-full flex-col gap-5"
@submit.prevent="submit"
>
<div class="w-full space-y-2">
<label
for="setup-username"
class="block text-sm font-medium text-default"
>
用户名
</label>
<UInput
id="setup-username"
v-model="username"
placeholder="3–32 位字母、数字或下划线"
autocomplete="username"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<div class="w-full space-y-2">
<label
for="setup-password"
class="block text-sm font-medium text-default"
>
密码
</label>
<UInput
id="setup-password"
v-model="password"
type="password"
placeholder="设置登录密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
<PasswordStrength :password="password" />
</div>
<div class="w-full space-y-2">
<label
for="setup-confirm"
class="block text-sm font-medium text-default"
>
确认密码
</label>
<UInput
id="setup-confirm"
v-model="confirmPassword"
type="password"
placeholder="再次输入密码"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
<label class="flex cursor-pointer items-center gap-2 text-sm text-default">
<UCheckbox v-model="allowRegistration" />
允许其他用户注册
</label>
<UButton
type="submit"
:label="isMigrate ? '完成迁移' : '创建并进入'"
icon="i-lucide-user-plus"
size="lg"
block
class="w-full"
:loading="loading"
:disabled="!canSubmit"
/>
</form>
</div>
</div>
</template>
+551
View File
@@ -0,0 +1,551 @@
<script setup lang="ts">
interface StatsResponse {
uploadToday: number
uploadMonth: number
deleteToday: number
deleteMonth: number
uploadTotal: number
deleteTotal: number
uploadBytesTotal: number
storedCount: number
userCount?: number
bySource: {
web: number
api: number
twikoo: number
}
byFolder: Array<{
folder: string
count: number
bytes: number
}>
byFolderUploads: Array<{
folder: string
count: number
}>
}
const {
items,
page: galleryPage,
totalPages: galleryTotalPages,
total: galleryTotal,
loading: galleryLoading,
searchQuery,
activeSearch,
listSummary,
refreshList,
initializeList,
setActiveFolder,
submitSearch,
goToPage,
removeItems
} = useImageList()
const { formatFileSize } = useFileSize()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const stats = ref<StatsResponse | null>(null)
const statsLoading = ref(false)
const selectedKeys = ref<Set<string>>(new Set())
const deletingKeys = ref<Set<string>>(new Set())
const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false)
const showScrollTop = ref(false)
const currentFolder = ref('all')
const folderOptions = ref<string[]>(['images'])
const folderSelectItems = computed(() => [
{ label: '全部', value: 'all' },
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
])
const selectedCount = computed(() => selectedKeys.value.size)
const FOLDER_CHART_COLORS = [
'var(--ui-primary)',
'#10b981',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4'
]
const folderUploadItems = computed(() =>
(stats.value?.byFolderUploads ?? []).map((item, index) => ({
key: item.folder,
label: item.folder,
count: item.count,
color: FOLDER_CHART_COLORS[index % FOLDER_CHART_COLORS.length]!
}))
)
const donutSegments = computed(() =>
folderUploadItems.value.map(item => ({
label: item.label,
value: item.count,
color: item.color
}))
)
const folderStats = computed(() => stats.value?.byFolder ?? [])
const folderMaxBytes = computed(() =>
Math.max(...folderStats.value.map(item => item.bytes), 1)
)
const uploadChartTotal = computed(() =>
folderUploadItems.value.reduce((sum, item) => sum + item.count, 0)
)
const statCards = computed(() => {
const s = stats.value
const base = [
{ label: '今日上传', value: s?.uploadToday ?? '—' },
{ label: '本月上传', value: s?.uploadMonth ?? '—' },
{ label: '累计上传', value: s?.uploadTotal ?? '—' },
{ label: '当前库存', value: s?.storedCount ?? '—' },
{ label: '今日删除', value: s?.deleteToday ?? '—' },
{ label: '本月删除', value: s?.deleteMonth ?? '—' },
isAdmin.value
? { label: '注册用户数量', value: s?.userCount ?? '—' }
: { label: '累计删除', value: s?.deleteTotal ?? '—' },
{ label: '累计上传体积', value: s ? formatFileSize(s.uploadBytesTotal) : '—' }
]
return base
})
function onWindowScroll() {
showScrollTop.value = window.scrollY > 400
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function folderPercent(bytes: number) {
return Math.round((bytes / folderMaxBytes.value) * 100)
}
async function loadFolders() {
const data = await $fetch<{ folders: string[] }>('/api/folders', {
credentials: 'include'
})
folderOptions.value = [...data.folders]
}
async function fetchStats() {
stats.value = await $fetch<StatsResponse>('/api/stats', {
credentials: 'include'
})
}
async function refreshStats() {
statsLoading.value = true
try {
await fetchStats()
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载统计失败', color: 'error' })
}
} finally {
statsLoading.value = false
}
}
async function refreshGallery() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await Promise.all([refreshList(), loadFolders()])
} catch (error: unknown) {
handleAuthError(error)
}
}
async function refreshAll() {
if (!isAuthenticated.value) return
currentFolder.value = 'all'
await Promise.all([
refreshStats(),
initializeList('all').then(() => loadFolders())
])
}
async function handleFolderChange(folder: string) {
if (!isAuthenticated.value) return
currentFolder.value = folder
selectedKeys.value = new Set()
try {
await setActiveFolder(folder)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleSearch() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await submitSearch()
} catch (error: unknown) {
handleAuthError(error)
}
}
async function clearSearch() {
searchQuery.value = ''
if (!activeSearch.value) return
await refreshGallery()
}
function requestDelete(key: string) {
if (!isAuthenticated.value) return
deleteTargetKeys.value = [key]
deleteModalOpen.value = true
}
function requestBatchDelete() {
if (!isAuthenticated.value) return
if (!selectedCount.value) {
toast.add({ title: '请先选择图片', color: 'warning' })
return
}
deleteTargetKeys.value = Array.from(selectedKeys.value)
deleteModalOpen.value = true
}
async function confirmDelete() {
const keys = [...deleteTargetKeys.value]
if (!keys.length || !isAuthenticated.value) return
batchDeleting.value = true
for (const key of keys) {
deletingKeys.value.add(key)
}
const deleted: string[] = []
const failed: string[] = []
try {
const response = await $fetch<{
deleted: string[]
failed: Array<{ key: string }>
}>('/api/images/batch-delete', {
method: 'POST',
body: { keys }
})
deleted.push(...response.deleted)
failed.push(...response.failed.map(item => item.key))
if (deleted.length) {
removeItems(deleted)
for (const key of deleted) {
selectedKeys.value.delete(key)
}
selectedKeys.value = new Set(selectedKeys.value)
toast.add({
title: `已删除 ${deleted.length} 张图片`,
color: 'success'
})
await refreshStats()
}
if (failed.length) {
toast.add({
title: `${failed.length} 张图片删除失败`,
color: 'error'
})
}
deleteModalOpen.value = false
deleteTargetKeys.value = []
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '删除失败', color: 'error' })
}
} finally {
batchDeleting.value = false
deletingKeys.value = new Set()
}
}
async function handleGalleryPageChange(targetPage: number) {
selectedKeys.value = new Set()
try {
await goToPage(targetPage)
} catch (error: unknown) {
handleAuthError(error)
}
}
onMounted(async () => {
window.addEventListener('scroll', onWindowScroll, { passive: true })
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
})
onUnmounted(() => {
window.removeEventListener('scroll', onWindowScroll)
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await refreshAll()
} else if (!authed) {
stats.value = null
items.value = []
selectedKeys.value = new Set()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<div class="flex flex-col items-center gap-3 text-muted">
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin"
/>
<p class="text-sm">
正在验证登录状态…
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">
统计
</h1>
<p class="text-sm text-muted">
数据概览与图库记录
</p>
</div>
<UButton
icon="i-lucide-refresh-cw"
label="刷新"
variant="outline"
:loading="statsLoading || galleryLoading"
@click="refreshAll"
/>
</div>
<section class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div
v-for="item in statCards"
:key="item.label"
class="rounded-xl border border-default bg-elevated p-4"
>
<p class="text-xs text-muted">
{{ item.label }}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">
{{ item.value }}
</p>
</div>
</section>
<section class="grid gap-4 lg:grid-cols-2">
<div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted">
上传目录分布
</h2>
<div
v-if="!uploadChartTotal"
class="py-6 text-center text-xs text-muted"
>
暂无数据
</div>
<StatsDonut
v-else
:segments="donutSegments"
:size="88"
/>
</div>
<div class="rounded-xl border border-default bg-elevated p-4">
<h2 class="mb-3 text-sm font-medium text-muted">
目录占用
</h2>
<div
v-if="!folderStats.length"
class="py-6 text-center text-xs text-muted"
>
暂无数据
</div>
<ul
v-else
class="space-y-2.5"
>
<li
v-for="item in folderStats"
:key="item.folder"
>
<div class="mb-1 flex items-center justify-between gap-2 text-xs">
<span class="font-medium">{{ item.folder }}</span>
<span class="tabular-nums text-muted">
{{ item.count }} 张 · {{ formatFileSize(item.bytes) }}
</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
class="h-full rounded-full bg-primary/70"
:style="{ width: `${Math.max(folderPercent(item.bytes), item.bytes ? 4 : 0)}%` }"
/>
</div>
</li>
</ul>
</div>
</section>
<section class="space-y-4">
<form
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
@submit.prevent="handleSearch"
>
<div class="min-w-0 shrink-0">
<h2 class="text-lg font-medium">
图库记录
</h2>
<p
v-if="!galleryLoading"
class="text-sm text-muted"
>
{{ listSummary }}
</p>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<div class="flex items-center gap-1.5">
<span class="text-xs text-muted shrink-0">目录</span>
<USelect
v-model="currentFolder"
:items="folderSelectItems"
class="w-28"
@update:model-value="handleFolderChange"
/>
</div>
<UInput
v-model="searchQuery"
icon="i-lucide-search"
placeholder="搜索文件名"
class="w-40 sm:w-48"
size="sm"
:disabled="galleryLoading"
/>
<UButton
type="submit"
label="搜索"
size="sm"
:loading="galleryLoading"
/>
<UButton
v-if="activeSearch"
type="button"
label="清除"
size="sm"
variant="ghost"
color="neutral"
@click="clearSearch"
/>
<UButton
type="button"
icon="i-lucide-refresh-cw"
label="刷新"
variant="outline"
size="sm"
:loading="galleryLoading"
@click="refreshGallery"
/>
<UBadge
v-if="selectedCount"
color="primary"
variant="subtle"
>
已选 {{ selectedCount }}
</UBadge>
<UButton
type="button"
icon="i-lucide-trash-2"
label="批量删除"
color="error"
variant="soft"
size="sm"
:disabled="!selectedCount"
@click="requestBatchDelete"
/>
</div>
</form>
<div
v-if="galleryLoading"
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<USkeleton
v-for="n in 12"
:key="n"
class="aspect-square rounded-lg"
/>
</div>
<ImageGrid
v-else
:items="items"
:selected-keys="selectedKeys"
:deleting-keys="deletingKeys"
@update:selected-keys="selectedKeys = $event"
@delete="requestDelete"
/>
<PaginationBar
v-if="!galleryLoading && galleryTotal > 0"
:page="galleryPage"
:total-pages="galleryTotalPages"
:total="galleryTotal"
unit="张"
:loading="galleryLoading"
@update:page="handleGalleryPageChange"
/>
</section>
<DeleteConfirmModal
v-model:open="deleteModalOpen"
:count="deleteTargetKeys.length"
:loading="batchDeleting"
@confirm="confirmDelete"
/>
<UButton
v-show="showScrollTop"
icon="i-lucide-arrow-up"
aria-label="回到顶部"
color="primary"
class="fixed bottom-6 right-6 z-50 shadow-lg"
size="lg"
@click="scrollToTop"
/>
</AppShell>
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
export type ErrorCode
= | 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'CONFLICT'
| 'INVALID_REQUEST'
| 'TOO_MANY_FILES'
| 'FILE_TOO_LARGE'
| 'UNSUPPORTED_FILE_TYPE'
| 'INVALID_FILE_SIGNATURE'
| 'INVALID_IMAGE_KEY'
| 'IMAGE_NOT_FOUND'
| 'UPLOAD_FAILED'
| 'DELETE_FAILED'
export interface ApiError {
code: ErrorCode
message: string
}
export interface ApiErrorResponse {
success: false
error: ApiError
}
export interface ImageItem {
key: string
url: string
originalName: string
contentType: string
size: number
uploadedAt: string
markdown: string
html: string
/** 管理员列表可见:上传者信息 */
owner?: {
userId: number | null
username: string
}
}
export interface UploadErrorItem {
name: string
error: ApiError
}
export interface UploadResponse {
success: boolean
items: ImageItem[]
errors: UploadErrorItem[]
}
export interface ImageListResponse {
items: ImageItem[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface DeleteResponse {
success: boolean
}
export interface BatchDeleteResponse {
success: boolean
deleted: string[]
failed: Array<{ key: string, error: ApiError }>
}
export interface UploadProgressItem {
name: string
status: 'pending' | 'uploading' | 'success' | 'error'
progress: number
error?: string
}
+39
View File
@@ -0,0 +1,39 @@
export const PASSWORD_MIN_LENGTH = 8
export interface PasswordCriteria {
minLength: boolean
mixedCase: boolean
hasNumber: boolean
hasSpecial: boolean
}
export type PasswordStrengthLabel = '弱' | '中' | '强'
export function analyzePassword(password: string): {
criteria: PasswordCriteria
score: number
label: PasswordStrengthLabel
isValid: boolean
} {
const criteria: PasswordCriteria = {
minLength: password.length >= PASSWORD_MIN_LENGTH,
mixedCase: /[a-z]/.test(password) && /[A-Z]/.test(password),
hasNumber: /\d/.test(password),
hasSpecial: /[^a-zA-Z0-9]/.test(password)
}
const score = Object.values(criteria).filter(Boolean).length
const label: PasswordStrengthLabel
= score <= 1 ? '弱' : score <= 3 ? '中' : '强'
return {
criteria,
score,
label,
isValid: criteria.minLength
}
}
export function isPasswordValid(password: string): boolean {
return analyzePassword(password).isValid
}
+17
View File
@@ -0,0 +1,17 @@
services:
pichost:
# Docker Hub 镜像(推送 main 分支后由 GitHub Actions 自动构建)
# 飞牛 Docker 对 Docker Hub 的「更新镜像」支持更好
image: muxui/pichost:latest
pull_policy: always
# 备用:GitHub Container Registry
# image: ghcr.io/O96u/pichost:latest
container_name: pichost
restart: unless-stopped
ports:
- "6892:6892"
# 首次访问 Web 引导创建管理员,无需预先配置环境变量
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
volumes:
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
- ./data:/data
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+6
View File
@@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const rootDir = dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')) as {
version: string
}
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [
'@nuxt/eslint',
'@nuxt/ui'
],
devtools: {
enabled: true
},
css: ['~/assets/css/main.css'],
colorMode: {
preference: 'system',
fallback: 'light'
},
runtimeConfig: {
appVersion: pkg.version,
devBypassAccess: process.env.DEV_BYPASS_ACCESS === 'true',
adminSecret: '',
imageBaseUrl: '',
apiUploadToken: ''
},
compatibilityDate: '2026-06-30',
nitro: {
preset: 'node-server',
scheduledTasks: {
'0 3 * * *': ['auto-delete']
}
},
eslint: {
config: {
stylistic: {
commaDangle: 'never',
braceStyle: '1tbs'
}
}
}
})
+16522
View File
File diff suppressed because it is too large. Load diff
+53
View File
@@ -0,0 +1,53 @@
{
"name": "pichost",
"version": "1.0.0",
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/O96u/PicHost.git"
},
"bugs": {
"url": "https://github.com/O96u/PicHost/issues"
},
"keywords": [
"image-hosting",
"self-hosted",
"nas",
"docker",
"nuxt",
"twikoo",
"easyimage"
],
"license": "MIT",
"private": true,
"type": "module",
"packageManager": "npm@11.12.1",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck",
"test": "vitest run",
"test:watch": "vitest",
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.114",
"@nuxt/ui": "^4.9.0",
"jose": "^6.0.11",
"nuxt": "^4.4.8",
"sharp": "^0.35.3",
"tailwindcss": "^4.3.1"
},
"devDependencies": {
"@nuxt/eslint": "^1.16.0",
"@types/node": "^22.15.0",
"eslint": "^10.6.0",
"typescript": "^6.0.3",
"vitest": "^3.2.4",
"vue-tsc": "^3.3.5"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"extends": [
"github>nuxt/renovate-config-nuxt"
],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [{
"matchDepTypes": ["resolutions"],
"enabled": false
}]
}
+13
View File
@@ -0,0 +1,13 @@
import { isAllowRegistration } from '../../utils/db'
import { isAdminSecretConfigured, isApiUploadTokenConfigured } from '../../utils/env'
import { isInitialized } from '../../utils/auth'
export default defineEventHandler((event) => {
return {
initialized: isInitialized(),
allowRegistration: isAllowRegistration(),
legacyMode: !isInitialized() && isAdminSecretConfigured(event),
adminSecretConfigured: isAdminSecretConfigured(event),
apiUploadTokenConfigured: isApiUploadTokenConfigured(event)
}
})
+86
View File
@@ -0,0 +1,86 @@
import {
checkAdminSecret,
setLegacyAuthCookie
} from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
createSession,
isInitialized,
verifyPassword
} from '../../utils/auth'
import { findUserByUsername } from '../../utils/db'
import { getAdminSecret } from '../../utils/env'
import { clientIp, logInfo, logWarn } from '../../utils/logger'
interface LoginBody {
username?: string
password?: string
secret?: string
}
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
const MAX_ATTEMPTS = 10
const WINDOW_MS = 15 * 60 * 1000
function checkRateLimit(ip: string): void {
const now = Date.now()
const entry = loginAttempts.get(ip)
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS })
return
}
entry.count += 1
if (entry.count > MAX_ATTEMPTS) {
throw createError({
statusCode: 429,
statusMessage: '登录尝试过于频繁,请稍后再试'
})
}
}
export default defineEventHandler(async (event) => {
const ip = clientIp(event)
checkRateLimit(ip)
const body = await readBody<LoginBody>(event)
const initialized = isInitialized()
if (!initialized) {
const adminSecret = getAdminSecret(event)
if (!adminSecret) {
createApiError(event, 'FORBIDDEN', '请先完成系统初始化', 403)
}
const input = body?.secret?.trim()
if (!input || !(await checkAdminSecret(input, adminSecret))) {
logWarn('legacy login failed: invalid secret', { ip })
createApiError(event, 'UNAUTHORIZED', '密钥错误', 401)
}
await setLegacyAuthCookie(event, adminSecret)
logInfo('legacy login success', { ip })
return { success: true, needsMigration: true }
}
const username = body?.username?.trim() ?? ''
const password = body?.password ?? ''
if (!username || !password) {
createApiError(event, 'INVALID_REQUEST', '请输入用户名和密码', 400)
}
const user = findUserByUsername(username)
if (!user || !verifyPassword(password, user.password_hash)) {
logWarn('login failed: invalid credentials', { ip, username })
createApiError(event, 'UNAUTHORIZED', '用户名或密码错误', 401)
}
await createSession(event, user.id)
logInfo('login success', { ip, username: user.username })
return {
success: true,
user: { id: user.id, username: user.username, role: user.role }
}
})
+10
View File
@@ -0,0 +1,10 @@
import { destroySession } from '../../utils/auth'
import { clearLegacyAuthCookie } from '../../utils/access'
import { clientIp, logInfo } from '../../utils/logger'
export default defineEventHandler(async (event) => {
await destroySession(event)
clearLegacyAuthCookie(event)
logInfo('logout', { ip: clientIp(event) })
return { success: true }
})
+14
View File
@@ -0,0 +1,14 @@
import { getCurrentUser } from '../../utils/auth'
import { createApiError } from '../../utils/api-error'
export default defineEventHandler(async (event) => {
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未登录', 401)
}
return {
authenticated: true,
user: { id: user.id, username: user.username, role: user.role }
}
})
+75
View File
@@ -0,0 +1,75 @@
import {
clearLegacyAuthCookie,
verifyLegacyAuth
} from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
createSession,
createUserAccount,
isInitialized
} from '../../utils/auth'
import { setAllowRegistration } from '../../utils/db'
import { clientIp, logInfo } from '../../utils/logger'
interface MigrateBody {
username?: string
password?: string
allowRegistration?: boolean
}
export default defineEventHandler(async (event) => {
if (isInitialized()) {
createApiError(event, 'FORBIDDEN', '系统已初始化', 403)
}
if (!(await verifyLegacyAuth(event))) {
createApiError(event, 'UNAUTHORIZED', '请先使用管理密钥登录', 401)
}
const body = await readBody<MigrateBody>(event)
const username = body?.username?.trim() ?? ''
const password = body?.password ?? ''
try {
const user = await createUserAccount({
username,
password,
role: 'admin'
})
setAllowRegistration(Boolean(body?.allowRegistration))
clearLegacyAuthCookie(event)
await createSession(event, user.id)
logInfo('legacy migration complete', {
ip: clientIp(event),
username: user.username
})
return {
success: true,
user: { id: user.id, username: user.username, role: user.role }
}
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message === 'INVALID_USERNAME') {
createApiError(
event,
'INVALID_REQUEST',
'用户名需为 3–32 位字母、数字或下划线',
400
)
} else if (error.message === 'INVALID_PASSWORD') {
createApiError(
event,
'INVALID_REQUEST',
'密码至少 8 位',
400
)
} else if (error.message === 'USERNAME_TAKEN') {
createApiError(event, 'INVALID_REQUEST', '用户名已被占用', 400)
}
}
throw error
}
})
+43
View File
@@ -0,0 +1,43 @@
import { createApiError } from '../../utils/api-error'
import { requireUserAuth } from '../../utils/access'
import {
hashPassword,
isValidPassword,
verifyPassword
} from '../../utils/auth'
import { findUserById, updateUserPassword } from '../../utils/db'
import { clientIp, logInfo } from '../../utils/logger'
interface PasswordBody {
currentPassword?: string
newPassword?: string
}
export default defineEventHandler(async (event) => {
const authUser = await requireUserAuth(event)
const body = await readBody<PasswordBody>(event)
const currentPassword = body?.currentPassword ?? ''
const newPassword = body?.newPassword ?? ''
if (!currentPassword || !newPassword) {
createApiError(event, 'INVALID_REQUEST', '请填写当前密码和新密码', 400)
}
if (!isValidPassword(newPassword)) {
createApiError(event, 'INVALID_REQUEST', '新密码至少 8 位', 400)
}
if (currentPassword === newPassword) {
createApiError(event, 'INVALID_REQUEST', '新密码不能与当前密码相同', 400)
}
const user = findUserById(authUser.id)
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
createApiError(event, 'UNAUTHORIZED', '当前密码错误', 401)
}
updateUserPassword(authUser.id, hashPassword(newPassword))
logInfo('password changed', { ip: clientIp(event), username: authUser.username })
return { success: true }
})
+65
View File
@@ -0,0 +1,65 @@
import { createApiError } from '../../utils/api-error'
import {
createSession,
createUserAccount,
isInitialized
} from '../../utils/auth'
import { isAllowRegistration } from '../../utils/db'
import { clientIp, logInfo } from '../../utils/logger'
interface RegisterBody {
username?: string
password?: string
}
export default defineEventHandler(async (event) => {
if (!isInitialized()) {
createApiError(event, 'FORBIDDEN', '请先完成系统初始化', 403)
}
if (!isAllowRegistration()) {
createApiError(event, 'FORBIDDEN', '当前未开放用户注册', 403)
}
const body = await readBody<RegisterBody>(event)
const username = body?.username?.trim() ?? ''
const password = body?.password ?? ''
try {
const user = await createUserAccount({
username,
password,
role: 'user'
})
await createSession(event, user.id)
logInfo('register success', { ip: clientIp(event), username: user.username })
return {
success: true,
user: { id: user.id, username: user.username, role: user.role }
}
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message === 'INVALID_USERNAME') {
createApiError(
event,
'INVALID_REQUEST',
'用户名需为 3–32 位字母、数字或下划线',
400
)
} else if (error.message === 'INVALID_PASSWORD') {
createApiError(
event,
'INVALID_REQUEST',
'密码至少 8 位',
400
)
} else if (error.message === 'USERNAME_TAKEN') {
createApiError(event, 'INVALID_REQUEST', '用户名已被占用', 400)
}
}
throw error
}
})
+24
View File
@@ -0,0 +1,24 @@
import { isAllowRegistration } from '../../utils/db'
import { isAdminSecretConfigured } from '../../utils/env'
import {
getCurrentUser,
isInitialized
} from '../../utils/auth'
import { verifyLegacyAuth } from '../../utils/access'
export default defineEventHandler(async (event) => {
const initialized = isInitialized()
const user = await getCurrentUser(event)
const legacyMode = !initialized && isAdminSecretConfigured(event)
const needsMigration = legacyMode && (await verifyLegacyAuth(event))
return {
initialized,
allowRegistration: isAllowRegistration(),
legacyMode,
needsMigration,
user: user
? { id: user.id, username: user.username, role: user.role }
: null
}
})
+11
View File
@@ -0,0 +1,11 @@
import { getImageUserFilter, requireUserAuth } from '../utils/access'
import { listFolders, listFoldersForUser } from '../utils/storage'
export default defineEventHandler(async (event) => {
await requireUserAuth(event)
const userFilter = await getImageUserFilter(event)
const folders = userFilter === 'admin'
? await listFolders()
: await listFoldersForUser(userFilter)
return { folders }
})
+7
View File
@@ -0,0 +1,7 @@
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event)
return {
status: 'ok',
version: config.appVersion
}
})
+104
View File
@@ -0,0 +1,104 @@
import type { BatchDeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { MAX_DELETE_BATCH } from '../../utils/constants'
import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage } from '../../utils/storage'
import { logInfo } from '../../utils/logger'
interface BatchDeleteBody {
keys?: string[]
}
// POST 版本:避免部分环境下 DELETE 请求体丢失
export default defineEventHandler(async (event) => {
try {
await requireApiOrAdminAuth(event)
const body = await readBody<BatchDeleteBody>(event)
const keys = body?.keys
if (!Array.isArray(keys) || !keys.length) {
createApiError(event, 'INVALID_REQUEST', '请提供要删除的图片列表', 400)
}
if (keys.length > MAX_DELETE_BATCH) {
createApiError(
event,
'INVALID_REQUEST',
`每次最多删除 ${MAX_DELETE_BATCH} 张图片`,
400
)
}
for (const key of keys) {
if (!validateImageKey(key)) {
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400)
}
}
const deleted: string[] = []
const failed: BatchDeleteResponse['failed'] = []
for (const key of keys) {
try {
const existing = await headImage(key)
if (!existing) {
failed.push({
key,
error: { code: 'IMAGE_NOT_FOUND', message: '图片不存在' }
})
continue
}
try {
await assertImageOwnership(event, existing.userId)
} catch {
failed.push({
key,
error: { code: 'FORBIDDEN', message: '无权操作此图片' }
})
continue
}
await deleteImage(key)
deleted.push(key)
insertActivityLog({
action: 'delete',
key,
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event)
})
logInfo('delete success', { key, size: existing.size })
} catch {
failed.push({
key,
error: { code: 'DELETE_FAILED', message: '删除失败' }
})
}
}
const response: BatchDeleteResponse = {
success: failed.length === 0,
deleted,
failed
}
if (!deleted.length && failed.length) {
setResponseStatus(event, 400)
}
return response
} catch (error: unknown) {
if (error && typeof error === 'object' && 'statusCode' in error) {
throw error
}
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
}
})
+16
View File
@@ -0,0 +1,16 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { isValidFolderName } from '../../utils/image-key'
import { countImages } from '../../utils/storage'
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const userFilter = await getImageUserFilter(event)
const query = getQuery(event)
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
const total = await countImages(folder, userFilter)
return { total }
})
+58
View File
@@ -0,0 +1,58 @@
import type { DeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage } from '../../utils/storage'
import { logInfo } from '../../utils/logger'
interface DeleteBody {
key?: string
}
export default defineEventHandler(async (event) => {
try {
await requireApiOrAdminAuth(event)
const query = getQuery(event)
const body = await readBody<DeleteBody>(event).catch(() => null)
const key = (typeof query.key === 'string' ? query.key : undefined)
?? body?.key
if (!key || !validateImageKey(key)) {
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
}
const existing = await headImage(key)
if (!existing) {
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
}
await assertImageOwnership(event, existing.userId)
try {
await deleteImage(key)
} catch {
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
}
insertActivityLog({
action: 'delete',
key,
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event)
})
logInfo('delete success', { key, size: existing.size })
const response: DeleteResponse = { success: true }
return response
} catch (error: unknown) {
if (error && typeof error === 'object' && 'statusCode' in error) {
throw error
}
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
}
})
+56
View File
@@ -0,0 +1,56 @@
import type { ImageListResponse } from '~/types/image'
import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access'
import { getCurrentUser } from '../../utils/auth'
import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { isValidFolderName } from '../../utils/image-key'
import { listImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
if (!raw || raw === 'all') return undefined
return isValidFolderName(raw) ? raw : undefined
}
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const query = getQuery(event)
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
: DEFAULT_LIST_LIMIT
const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw)
: 1
const folder = readFolderQuery(query)
const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, folder, userFilter })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const items = listing.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap })
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
const response: ImageListResponse = {
items,
total: listing.total,
page: listing.page,
pageSize: listing.pageSize,
totalPages: listing.totalPages
}
return response
})
+66
View File
@@ -0,0 +1,66 @@
import type { ImageListResponse } from '~/types/image'
import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access'
import { getCurrentUser } from '../../utils/auth'
import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { isValidFolderName } from '../../utils/image-key'
import { searchImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
if (!raw || raw === 'all') return undefined
return isValidFolderName(raw) ? raw : undefined
}
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const query = getQuery(event)
const q = typeof query.q === 'string' ? query.q.trim() : ''
if (!q) {
createApiError(event, 'INVALID_REQUEST', '请提供搜索关键词 q', 400)
}
if (q.length > 200) {
createApiError(event, 'INVALID_REQUEST', '搜索关键词过长', 400)
}
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
: DEFAULT_LIST_LIMIT
const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw)
: 1
const folder = readFolderQuery(query)
const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, folder, userFilter })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const items = result.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap })
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
const response: ImageListResponse = {
items,
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages
}
return response
})
+145
View File
@@ -0,0 +1,145 @@
import { readMultipartFormData } from 'h3'
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId, canUseCustomUploadFolder } from '../../utils/access'
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
import {
detectMimeFromSignature,
isAllowedMimeType
} from '../../utils/file-signature'
import {
mimeMatchesDeclared,
processSingleImageUpload
} from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error'
import {
DEFAULT_FOLDER,
isValidFolderName,
normalizeFolderName
} from '../../utils/image-key'
function readFormText(
formData: { name?: string, data?: Buffer | Uint8Array }[],
name: string
): string {
const part = formData.find(item => item.name === name && item.data?.length)
if (!part?.data) return ''
return new TextDecoder().decode(part.data).trim()
}
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const formData = await readMultipartFormData(event)
if (!formData?.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
const query = getQuery(event)
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
const folderFromQuery = typeof query.folder === 'string'
? query.folder
: typeof query.type === 'string'
? query.type
: ''
const requestedFolder = normalizeFolderName(folderFromForm || folderFromQuery)
const canCustomFolder = await canUseCustomUploadFolder(event)
if (!canCustomFolder && requestedFolder && requestedFolder !== DEFAULT_FOLDER) {
createApiError(
event,
'FORBIDDEN',
'当前账号不支持自定义上传目录',
403
)
}
const folder = canCustomFolder ? requestedFolder : ''
if (folder && !isValidFolderName(folder)) {
createApiError(
event,
'INVALID_REQUEST',
'无效的 folder:仅允许字母数字开头,可含 - _,最长 32 位,且不能是 api/stats 等保留名',
400
)
}
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter(
part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
&& part.data?.length
)
if (!fileParts.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
if (fileParts.length > MAX_FILES_PER_UPLOAD) {
createApiError(
event,
'TOO_MANY_FILES',
`每次最多上传 ${MAX_FILES_PER_UPLOAD} 张图片`,
400
)
}
const items: ImageItem[] = []
const errors: UploadErrorItem[] = []
for (const part of fileParts) {
const originalName = part.filename ?? 'image'
const bytes = new Uint8Array(part.data)
const declaredMime = part.type?.toLowerCase() ?? ''
const detectedMime = detectMimeFromSignature(bytes)
if (detectedMime && !mimeMatchesDeclared(declaredMime, detectedMime)) {
errors.push({
name: originalName,
error: {
code: 'INVALID_FILE_SIGNATURE',
message: '文件类型与内容不匹配'
}
})
continue
}
if (declaredMime && isAllowedMimeType(declaredMime) && !detectedMime) {
errors.push({
name: originalName,
error: {
code: 'INVALID_FILE_SIGNATURE',
message: '无法识别的图片格式'
}
})
continue
}
const result = await processSingleImageUpload(event, {
bytes,
filename: part.filename,
source,
prefix: folder || DEFAULT_FOLDER,
userId: uploadUserId
})
if ('error' in result) {
errors.push(result.error)
} else {
items.push(result.item)
}
}
const response: UploadResponse = {
success: items.length > 0,
items,
errors
}
if (!items.length && errors.length) {
setResponseStatus(event, 400)
}
return response
})
+64
View File
@@ -0,0 +1,64 @@
import { requireAdminAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
listActivityLogs,
type LogAction,
type LogSource
} from '../../utils/db'
import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const query = getQuery(event)
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
: DEFAULT_LIST_LIMIT
const actionRaw = typeof query.action === 'string' ? query.action : ''
const action = (actionRaw === 'upload' || actionRaw === 'delete')
? actionRaw as LogAction
: undefined
const sourceRaw = typeof query.source === 'string' ? query.source : ''
const source = (sourceRaw === 'web' || sourceRaw === 'api' || sourceRaw === 'twikoo')
? sourceRaw as LogSource
: undefined
const folder = typeof query.folder === 'string' && query.folder.trim()
? query.folder.trim()
: undefined
const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw)
: 1
try {
const result = listActivityLogs({ limit, page, action, source, folder })
return {
items: result.items.map(row => ({
id: row.id,
action: row.action,
key: row.key,
originalName: row.original_name,
size: row.size,
contentType: row.content_type,
source: row.source,
createdAt: row.created_at
})),
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages
}
} catch (error) {
console.error('[logs] list failed', error)
createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500)
}
})
@@ -0,0 +1,29 @@
import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import {
generateApiUploadToken,
setSetting,
SETTINGS_API_UPLOAD_TOKEN
} from '../../../utils/db'
import { isApiUploadTokenEnvConfigured } from '../../../utils/env'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isApiUploadTokenEnvConfigured(event)) {
createApiError(
event,
'CONFLICT',
'API_UPLOAD_TOKEN 已在环境变量中配置,无法在后台重新生成',
409
)
}
const token = generateApiUploadToken()
setSetting(SETTINGS_API_UPLOAD_TOKEN, token)
return {
apiUploadToken: token,
tokenSource: 'db' as const
}
})
+7
View File
@@ -0,0 +1,7 @@
import { requireAdminAuth } from '../../utils/access'
import { getSettingsPayload } from '../../utils/env'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
return getSettingsPayload(event)
})
+97
View File
@@ -0,0 +1,97 @@
import { requireAdminAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_WEBP_QUALITY,
setAllowRegistration
} from '../../utils/db'
import {
getSettingsPayload,
isValidImageBaseUrl,
isValidRefererHost,
MAX_AUTO_DELETE_DAYS,
normalizeImageBaseUrl,
normalizeRefererHosts,
parseAutoDeleteDays,
parseWebpQuality,
setGlobalAutoDeletePolicy
} from '../../utils/env'
interface SettingsPatchBody {
webpQuality?: unknown
allowedRefererHosts?: unknown
imageBaseUrl?: unknown
autoDeleteDays?: unknown
allowRegistration?: unknown
}
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const body = await readBody<SettingsPatchBody>(event).catch(
(): SettingsPatchBody => ({})
)
if (body.webpQuality !== undefined) {
const quality = parseWebpQuality(String(body.webpQuality))
if (quality === null) {
createApiError(event, 'INVALID_REQUEST', '压缩质量需为 1–100 的整数', 400)
}
setSetting(SETTINGS_WEBP_QUALITY, String(quality))
}
if (body.allowedRefererHosts !== undefined) {
const raw = typeof body.allowedRefererHosts === 'string'
? body.allowedRefererHosts
: ''
const normalized = normalizeRefererHosts(raw)
if (normalized) {
const invalid = normalized.split(',').find(host => !isValidRefererHost(host))
if (invalid) {
createApiError(
event,
'INVALID_REQUEST',
`无效的 Referer 域名:${invalid}`,
400
)
}
}
setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized)
}
if (body.imageBaseUrl !== undefined) {
const configured = normalizeImageBaseUrl(
typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : ''
)
if (configured && !isValidImageBaseUrl(configured)) {
createApiError(
event,
'INVALID_REQUEST',
'IMAGE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名',
400
)
}
setSetting(SETTINGS_IMAGE_BASE_URL, configured)
}
if (body.autoDeleteDays !== undefined) {
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
if (days === null) {
createApiError(
event,
'INVALID_REQUEST',
`自动删除天数需为 0–${MAX_AUTO_DELETE_DAYS} 的整数(0 表示关闭)`,
400
)
}
setGlobalAutoDeletePolicy(days)
}
if (body.allowRegistration !== undefined) {
setAllowRegistration(Boolean(body.allowRegistration))
}
return getSettingsPayload(event)
})
+63
View File
@@ -0,0 +1,63 @@
import { createApiError } from '../utils/api-error'
import {
createSession,
createUserAccount,
isInitialized
} from '../utils/auth'
import { setAllowRegistration } from '../utils/db'
import { clientIp, logInfo } from '../utils/logger'
interface SetupBody {
username?: string
password?: string
allowRegistration?: boolean
}
export default defineEventHandler(async (event) => {
if (isInitialized()) {
createApiError(event, 'FORBIDDEN', '系统已初始化', 403)
}
const body = await readBody<SetupBody>(event)
const username = body?.username?.trim() ?? ''
const password = body?.password ?? ''
try {
const user = await createUserAccount({
username,
password,
role: 'admin'
})
setAllowRegistration(Boolean(body?.allowRegistration))
await createSession(event, user.id)
logInfo('setup complete', { ip: clientIp(event), username: user.username })
return {
success: true,
user: { id: user.id, username: user.username, role: user.role }
}
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message === 'INVALID_USERNAME') {
createApiError(
event,
'INVALID_REQUEST',
'用户名需为 3–32 位字母、数字或下划线',
400
)
} else if (error.message === 'INVALID_PASSWORD') {
createApiError(
event,
'INVALID_REQUEST',
'密码至少 8 位',
400
)
} else if (error.message === 'USERNAME_TAKEN') {
createApiError(event, 'INVALID_REQUEST', '用户名已被占用', 400)
}
}
throw error
}
})
+52
View File
@@ -0,0 +1,52 @@
import { getImageUserFilter, requireUserAuth } from '../utils/access'
import { createApiError } from '../utils/api-error'
import { countUsers, getActivityStats } from '../utils/db'
import {
countImages,
getFolderStorageStats,
getUserScopedStorageStats
} from '../utils/storage'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const userFilter = await getImageUserFilter(event)
try {
if (user.role === 'admin' && userFilter === 'admin') {
const [activity, storedCount, byFolder] = await Promise.all([
Promise.resolve(getActivityStats()),
countImages(),
getFolderStorageStats()
])
return {
...activity,
storedCount,
byFolder,
userCount: countUsers()
}
}
const scoped = await getUserScopedStorageStats(user.id)
return {
uploadToday: scoped.uploadToday,
uploadMonth: scoped.uploadMonth,
deleteToday: 0,
deleteMonth: 0,
uploadTotal: scoped.storedCount,
deleteTotal: 0,
uploadBytesTotal: scoped.uploadBytesTotal,
bySource: { web: scoped.storedCount, api: 0, twikoo: 0 },
byFolderUploads: scoped.byFolder.map(item => ({
folder: item.folder,
count: item.count
})),
storedCount: scoped.storedCount,
byFolder: scoped.byFolder
}
} catch (error) {
console.error('[stats] failed', error)
createApiError(event, 'INVALID_REQUEST', '读取统计失败', 500)
}
})
+20
View File
@@ -0,0 +1,20 @@
import { requireUserAuth } from '../../utils/access'
import { ensureUserApiToken } from '../../utils/db'
import { getImageBaseUrl } from '../../utils/env'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const config = useRuntimeConfig(event)
return {
apiUploadToken: ensureUserApiToken(user.id),
tokenSource: 'db' as const,
envTokenOverride: false,
env: {
webpQuality: 80,
refererConfigured: false,
imageBaseUrl: getImageBaseUrl(event),
appVersion: config.appVersion as string
}
}
})
@@ -0,0 +1,10 @@
import { requireUserAuth } from '../../../utils/access'
import { generateApiUploadToken, setUserApiToken } from '../../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const token = generateApiUploadToken()
setUserApiToken(user.id, token)
return { apiUploadToken: token }
})
+11
View File
@@ -0,0 +1,11 @@
import { requireUserAuth } from '../../utils/access'
import { getUserAutoDeletePolicy } from '../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const policy = getUserAutoDeletePolicy(user.id)
return {
autoDeleteDays: policy.days
}
})
+34
View File
@@ -0,0 +1,34 @@
import { requireUserAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { getUserAutoDeletePolicy, setUserAutoDeletePolicy } from '../../utils/db'
import { MAX_AUTO_DELETE_DAYS, parseAutoDeleteDays } from '../../utils/env'
interface UserSettingsPatchBody {
autoDeleteDays?: unknown
}
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const body = await readBody<UserSettingsPatchBody>(event).catch(
(): UserSettingsPatchBody => ({})
)
if (body.autoDeleteDays !== undefined) {
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
if (days === null) {
createApiError(
event,
'INVALID_REQUEST',
`自动删除天数需为 0–${MAX_AUTO_DELETE_DAYS} 的整数(0 表示关闭)`,
400
)
}
setUserAutoDeletePolicy(user.id, days)
}
const policy = getUserAutoDeletePolicy(user.id)
return {
autoDeleteDays: policy.days
}
})
+15
View File
@@ -0,0 +1,15 @@
import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image'
/**
* 仅当路径像图片 key({type}/YYYY/MM/file.ext)时才接管。
* 其它请求(/_nuxt、/api、页面)原样放行。
*/
export default defineEventHandler(async (event) => {
const method = getMethod(event)
if (method !== 'GET' && method !== 'HEAD') return
const key = requestPathToImageKey(getRequestURL(event).pathname)
if (!key) return
return serveImageByKey(event, key)
})
+29
View File
@@ -0,0 +1,29 @@
import { clientIp, logInfo } from '../utils/logger'
/**
* 只记录 /api/*,避免 /images、/twikoo 直链刷屏。
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', (event) => {
event.context._reqStartedAt = Date.now()
})
nitroApp.hooks.hook('afterResponse', (event) => {
const path = getRequestURL(event).pathname
if (!path.startsWith('/api/')) return
// 高频探测接口降噪
if (path === '/api/auth/me' || path === '/api/auth/config') return
const method = getMethod(event)
const status = getResponseStatus(event)
const started = event.context._reqStartedAt as number | undefined
const ms = started ? Date.now() - started : undefined
logInfo(`${method} ${path}`, {
status,
ms,
ip: clientIp(event)
})
})
})
+20
View File
@@ -0,0 +1,20 @@
import { getDataDir } from '../utils/storage'
import { runAutoDeleteCleanup } from '../utils/auto-delete'
import { logInfo } from '../utils/logger'
export default defineNitroPlugin(() => {
const port = process.env.NITRO_PORT || process.env.PORT || '6892'
logInfo('PicHost starting', {
port,
dataDir: getDataDir(),
nodeEnv: process.env.NODE_ENV || 'development',
imageBaseUrl: process.env.IMAGE_BASE_URL || '(request origin)',
logLevel: process.env.LOG_LEVEL || 'info'
})
void runAutoDeleteCleanup().then((result) => {
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
logInfo('startup auto-delete', { ...result })
}
})
})
+101
View File
@@ -0,0 +1,101 @@
import { readMultipartFormData } from 'h3'
import { verifyUploadTokenValue } from '../../utils/access'
import {
detectMimeFromSignature,
isAllowedMimeType
} from '../../utils/file-signature'
import {
mimeMatchesDeclared,
processSingleImageUpload
} from '../../utils/process-image-upload'
import { getApiUploadToken } from '../../utils/env'
/** EasyImages 2.0 兼容上传(Twikoo IMAGE_CDN=easyimage) */
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json; charset=utf-8')
const config = useRuntimeConfig(event)
const devBypass = import.meta.dev && config.devBypassAccess
const formData = await readMultipartFormData(event)
if (!formData?.length) {
return easyImageError(400, '未收到上传数据')
}
const tokenField = formData.find(part => part.name === 'token')
const token = tokenField?.data?.length
? new TextDecoder().decode(tokenField.data).trim()
: ''
if (!devBypass) {
if (!getApiUploadToken(event)) {
return easyImageError(500, '服务端未配置 API_UPLOAD_TOKEN')
}
if (!verifyUploadTokenValue(event, token)) {
return easyImageError(401, 'token 错误或无效')
}
}
const imagePart = formData.find(
part => part.name === 'image' && part.data?.length
)
if (!imagePart?.data?.length) {
return easyImageError(400, '未收到图片文件')
}
const bytes = new Uint8Array(imagePart.data)
const declaredMime = imagePart.type?.toLowerCase() ?? ''
const detectedMime = detectMimeFromSignature(bytes)
if (detectedMime && !mimeMatchesDeclared(declaredMime, detectedMime)) {
return easyImageError(415, '文件类型与内容不匹配')
}
if (declaredMime && isAllowedMimeType(declaredMime) && !detectedMime) {
return easyImageError(415, '无法识别的图片格式')
}
// Twikoo 评论图片单独存放在 twikoo/ 目录下
const result = await processSingleImageUpload(event, {
bytes,
filename: imagePart.filename,
prefix: 'twikoo',
source: 'twikoo'
})
if ('error' in result) {
const code = easyImageCodeFromError(result.error.error.code)
return easyImageError(code, result.error.error.message)
}
const { item } = result
return {
result: 'success',
code: 200,
url: item.url,
srcName: item.originalName,
thumb: item.url,
del: ''
}
})
function easyImageError(code: number, message: string) {
return {
result: 'failed',
code,
message
}
}
function easyImageCodeFromError(code: string): number {
switch (code) {
case 'FILE_TOO_LARGE':
return 413
case 'UNSUPPORTED_FILE_TYPE':
case 'INVALID_FILE_SIGNATURE':
return 415
case 'UPLOAD_FAILED':
return 500
default:
return 400
}
}
+11
View File
@@ -0,0 +1,11 @@
import { runAutoDeleteCleanup } from '../utils/auto-delete'
export default defineTask({
meta: {
name: 'auto-delete',
description: 'Delete images older than auto_delete_days'
},
async run() {
return { result: await runAutoDeleteCleanup() }
}
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { validateImageKey } from './image-key'
/**
* Mirrors assertImageOwnership rules (access.ts) for documentation + regression.
*/
function canOperateImage(
actor: { type: 'user', id: number, role: 'user' | 'admin' }
| { type: 'user-token', id: number }
| { type: 'global-token' },
ownerId: number | null
): boolean {
if (actor.type === 'global-token') return true
if (actor.type === 'user-token') {
return ownerId === actor.id
}
if (actor.role === 'admin') return true
return ownerId !== null && ownerId === actor.id
}
describe('image ownership rules', () => {
it('regular user can only operate own images', () => {
const user1 = { type: 'user' as const, id: 1, role: 'user' as const }
expect(canOperateImage(user1, 1)).toBe(true)
expect(canOperateImage(user1, 2)).toBe(false)
expect(canOperateImage(user1, null)).toBe(false)
})
it('admin can operate any image', () => {
const admin = { type: 'user' as const, id: 1, role: 'admin' as const }
expect(canOperateImage(admin, 2)).toBe(true)
expect(canOperateImage(admin, null)).toBe(true)
})
it('user API token is scoped to owner', () => {
const token = { type: 'user-token' as const, id: 2 }
expect(canOperateImage(token, 2)).toBe(true)
expect(canOperateImage(token, 1)).toBe(false)
expect(canOperateImage(token, null)).toBe(false)
})
it('global API token can operate any image', () => {
const token = { type: 'global-token' as const }
expect(canOperateImage(token, 1)).toBe(true)
expect(canOperateImage(token, null)).toBe(true)
})
})
describe('path traversal hardening', () => {
it('rejects keys that could escape data dir', () => {
const attacks = [
'images/2026/08/../secret.webp',
'images/../../../etc/passwd',
'images//2026/08/a.webp',
'/images/2026/08/a.webp',
'images/2026/08/%2e%2e/other.webp'
]
for (const key of attacks) {
expect(validateImageKey(key), key).toBe(false)
}
})
})
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { createLegacySessionToken } from './access'
describe('createLegacySessionToken', () => {
it('returns a stable hex digest for the same secret', async () => {
const first = await createLegacySessionToken('test-secret')
const second = await createLegacySessionToken('test-secret')
expect(first).toBe(second)
expect(first).toMatch(/^[0-9a-f]{64}$/)
})
it('changes when the secret changes', async () => {
const a = await createLegacySessionToken('secret-a')
const b = await createLegacySessionToken('secret-b')
expect(a).not.toBe(b)
})
})
+256
View File
@@ -0,0 +1,256 @@
import type { H3Event } from 'h3'
import { findUserByApiToken } from './db'
import { getAdminSecret, getApiUploadToken } from './env'
import { createApiError } from './api-error'
import {
getCurrentUser,
type AuthUser
} from './auth'
const LEGACY_AUTH_COOKIE = 'pic_auth'
const LEGACY_SESSION_SALT = 'pic-session'
const LEGACY_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
let result = 0
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
}
return result === 0
}
export async function createLegacySessionToken(secret: string): Promise<string> {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(LEGACY_SESSION_SALT))
return Array.from(new Uint8Array(sig), byte => byte.toString(16).padStart(2, '0')).join('')
}
export async function setLegacyAuthCookie(event: H3Event, secret: string): Promise<void> {
const token = await createLegacySessionToken(secret)
const isHttps = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).protocol === 'https:'
setCookie(event, LEGACY_AUTH_COOKIE, token, {
httpOnly: true,
secure: isHttps,
sameSite: 'lax',
path: '/',
maxAge: LEGACY_COOKIE_MAX_AGE
})
}
export function clearLegacyAuthCookie(event: H3Event): void {
deleteCookie(event, LEGACY_AUTH_COOKIE, { path: '/' })
}
export async function verifyLegacyAuth(event: H3Event): Promise<boolean> {
const adminSecret = getAdminSecret(event)
if (!adminSecret) return false
const cookie = getCookie(event, LEGACY_AUTH_COOKIE)
if (!cookie) return false
const expected = await createLegacySessionToken(adminSecret)
return timingSafeEqual(cookie, expected)
}
export async function checkAdminSecret(
input: string,
secret: string
): Promise<boolean> {
return timingSafeEqual(input, secret)
}
function extractApiToken(event: H3Event): string {
const token = getHeader(event, 'auth-token')
return token?.trim() ?? ''
}
export function verifyGlobalApiUploadToken(event: H3Event): boolean {
const expected = getApiUploadToken(event)
if (!expected) return false
const token = extractApiToken(event)
if (!token) return false
return timingSafeEqual(token, expected)
}
export function resolveUserIdFromApiToken(event: H3Event): number | null {
const token = extractApiToken(event)
if (!token) return null
return findUserByApiToken(token)?.id ?? null
}
export function verifyApiUploadToken(event: H3Event): boolean {
return verifyGlobalApiUploadToken(event)
|| resolveUserIdFromApiToken(event) !== null
}
/** EasyImage 等图床在表单字段 token 中传 API_UPLOAD_TOKEN */
export function verifyUploadTokenValue(event: H3Event, token: string): boolean {
const expected = getApiUploadToken(event)
if (expected && token && timingSafeEqual(token, expected)) {
return true
}
return findUserByApiToken(token) !== null
}
function devBypass(event: H3Event): boolean {
const config = useRuntimeConfig(event)
return import.meta.dev && Boolean(config.devBypassAccess)
}
export async function requireUserAuth(event: H3Event): Promise<AuthUser> {
if (devBypass(event)) {
return { id: 0, username: 'dev', role: 'admin' }
}
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
}
return user
}
export async function requireAdminAuth(event: H3Event): Promise<AuthUser> {
const user = await requireUserAuth(event)
if (user.role !== 'admin') {
createApiError(event, 'FORBIDDEN', '需要管理员权限', 403)
}
return user
}
export async function requireUploadAuth(event: H3Event): Promise<void> {
if (devBypass(event)) {
return
}
if (verifyApiUploadToken(event)) {
return
}
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
}
}
/** 图库读删等管理接口:API Token 或网页登录 Session */
export async function requireApiOrAdminAuth(event: H3Event): Promise<void> {
if (devBypass(event)) {
return
}
if (verifyApiUploadToken(event)) {
return
}
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
}
}
/** 列表/搜索/删除时按用户过滤;全局 Token 视为管理员,用户 Token 仅见自己的 */
export async function getImageUserFilter(
event: H3Event
): Promise<number | 'admin'> {
if (devBypass(event)) {
return 'admin'
}
const userIdFromToken = resolveUserIdFromApiToken(event)
if (userIdFromToken !== null) {
return userIdFromToken
}
if (verifyGlobalApiUploadToken(event)) {
return 'admin'
}
const user = await requireUserAuth(event)
return user.role === 'admin' ? 'admin' : user.id
}
export async function assertImageOwnership(
event: H3Event,
userId: number | null | undefined
): Promise<void> {
if (devBypass(event)) {
return
}
const userIdFromToken = resolveUserIdFromApiToken(event)
if (userIdFromToken !== null) {
const ownerId = userId ?? null
if (ownerId !== userIdFromToken) {
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
}
return
}
if (verifyGlobalApiUploadToken(event)) {
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)
}
}
export function resolveActivitySource(event: H3Event): 'api' | 'web' {
return verifyApiUploadToken(event) ? 'api' : 'web'
}
export async function getUploadUserId(event: H3Event): Promise<number | null> {
if (devBypass(event)) {
return null
}
const userIdFromToken = resolveUserIdFromApiToken(event)
if (userIdFromToken !== null) {
return userIdFromToken
}
if (verifyGlobalApiUploadToken(event)) {
return null
}
const user = await getCurrentUser(event)
return user?.id ?? null
}
/** 管理员会话 / 全局 API Token 可使用自定义上传目录 */
export async function canUseCustomUploadFolder(event: H3Event): Promise<boolean> {
if (devBypass(event)) {
return true
}
if (verifyGlobalApiUploadToken(event)) {
return true
}
if (resolveUserIdFromApiToken(event) !== null) {
return false
}
const user = await getCurrentUser(event)
return user?.role === 'admin'
}
+25
View File
@@ -0,0 +1,25 @@
import type { H3Event } from 'h3'
import type { ApiError, ErrorCode } from '~/types/image'
export function createApiError(
event: H3Event,
code: ErrorCode,
message: string,
statusCode = 400
): never {
throw createError({
statusCode,
statusMessage: message,
data: {
success: false,
error: { code, message } satisfies ApiError
}
})
}
export function isApiErrorResponse(data: unknown): data is { error: ApiError } {
return typeof data === 'object'
&& data !== null
&& 'error' in data
&& typeof (data as { error: unknown }).error === 'object'
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { hashPassword, isValidPassword, isValidUsername, verifyPassword } from './auth'
describe('auth password', () => {
it('hashes and verifies a password', () => {
const hash = hashPassword('test-password-123')
expect(hash.startsWith('scrypt$')).toBe(true)
expect(verifyPassword('test-password-123', hash)).toBe(true)
expect(verifyPassword('wrong-password', hash)).toBe(false)
})
it('rejects invalid passwords', () => {
expect(isValidPassword('short')).toBe(false)
expect(isValidPassword('long-enough')).toBe(true)
})
it('validates usernames', () => {
expect(isValidUsername('ab')).toBe(false)
expect(isValidUsername('user_1')).toBe(true)
expect(isValidUsername('bad name')).toBe(false)
})
})
+197
View File
@@ -0,0 +1,197 @@
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
import type { H3Event } from 'h3'
import {
countUsers,
createSessionRow,
deleteSession,
findSessionById,
findUserById,
findUserByUsername,
insertUser,
type UserRole,
type UserRow
} from './db'
const SCRYPT_N = 16384
const SCRYPT_R = 8
const SCRYPT_P = 1
const SCRYPT_KEY_LEN = 64
const MIN_PASSWORD_LENGTH = 8
const USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
export const SESSION_COOKIE = 'pic_session'
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7 // 7 days
export interface AuthUser {
id: number
username: string
role: UserRole
}
function timingSafeEqualStr(a: string, b: string): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
export function isValidUsername(username: string): boolean {
return USERNAME_PATTERN.test(username)
}
export function isValidPassword(password: string): boolean {
return password.length >= MIN_PASSWORD_LENGTH
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex')
const derived = scryptSync(password, salt, SCRYPT_KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P
})
return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt}$${derived.toString('hex')}`
}
export function verifyPassword(password: string, stored: string): boolean {
const parts = stored.split('$')
if (parts.length !== 6 || parts[0] !== 'scrypt') return false
const N = Number(parts[1])
const r = Number(parts[2])
const p = Number(parts[3])
const salt = parts[4]
const hashHex = parts[5]
if (!salt || !hashHex || !Number.isFinite(N)) return false
try {
const derived = scryptSync(password, salt, SCRYPT_KEY_LEN, { N, r, p })
return timingSafeEqualStr(derived.toString('hex'), hashHex)
} catch {
return false
}
}
function hashSessionToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
function generateSessionToken(): string {
return randomBytes(32).toString('hex')
}
function sessionExpiresAt(): string {
return new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000).toISOString()
}
function cookieSecure(event: H3Event): boolean {
return getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).protocol === 'https:'
}
export function setSessionCookie(event: H3Event, sessionId: string): void {
setCookie(event, SESSION_COOKIE, sessionId, {
httpOnly: true,
secure: cookieSecure(event),
sameSite: 'lax',
path: '/',
maxAge: SESSION_MAX_AGE_SEC
})
}
export function clearSessionCookie(event: H3Event): void {
deleteCookie(event, SESSION_COOKIE, { path: '/' })
}
export async function createSession(
event: H3Event,
userId: number
): Promise<string> {
const sessionId = randomBytes(24).toString('hex')
const token = generateSessionToken()
const tokenHash = hashSessionToken(token)
const expiresAt = sessionExpiresAt()
const createdAt = new Date().toISOString()
createSessionRow({
id: sessionId,
userId,
tokenHash,
expiresAt,
createdAt
})
setSessionCookie(event, sessionId)
return sessionId
}
export async function destroySession(event: H3Event): Promise<void> {
const sessionId = getCookie(event, SESSION_COOKIE)
if (sessionId) {
deleteSession(sessionId)
}
clearSessionCookie(event)
}
export function rowToAuthUser(row: UserRow): AuthUser {
return {
id: row.id,
username: row.username,
role: row.role
}
}
export async function getCurrentUser(event: H3Event): Promise<AuthUser | null> {
const sessionId = getCookie(event, SESSION_COOKIE)
if (!sessionId) return null
const session = findSessionById(sessionId)
if (!session) return null
if (new Date(session.expires_at) <= new Date()) {
deleteSession(sessionId)
return null
}
const user = findUserById(session.user_id)
if (!user) return null
return rowToAuthUser(user)
}
export async function createUserAccount(input: {
username: string
password: string
role: UserRole
}): Promise<AuthUser> {
if (!isValidUsername(input.username)) {
throw new Error('INVALID_USERNAME')
}
if (!isValidPassword(input.password)) {
throw new Error('INVALID_PASSWORD')
}
const existing = findUserByUsername(input.username)
if (existing) {
throw new Error('USERNAME_TAKEN')
}
const passwordHash = hashPassword(input.password)
const createdAt = new Date().toISOString()
const id = insertUser({
username: input.username,
passwordHash,
role: input.role,
createdAt
})
return {
id,
username: input.username,
role: input.role
}
}
export function isInitialized(): boolean {
return countUsers() > 0
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { shouldAutoDeleteImage } from './auto-delete'
import type { StoredImage } from './storage'
function image(userId: number | null, uploadedAt: string): StoredImage {
return {
key: 'images/2026/01/a.webp',
originalName: 'a.webp',
uploadedAt,
contentType: 'image/webp',
size: 100,
mtimeMs: 0,
userId
}
}
const nowMs = Date.parse('2026-09-15T00:00:00.000Z')
const enabledAt = '2026-08-01T00:00:00.000Z'
const adminIds = new Set([1])
describe('shouldAutoDeleteImage', () => {
it('deletes user images by their own policy after enabled', () => {
const userPolicies = new Map([[2, { days: 30, enabledAt }]])
const uploadedAt = '2026-07-01T00:00:00.000Z'
expect(shouldAutoDeleteImage(
image(2, uploadedAt),
{ days: 0, enabledAt: null },
adminIds,
userPolicies,
nowMs
)).toBe(false)
expect(shouldAutoDeleteImage(
image(2, '2026-08-05T00:00:00.000Z'),
{ days: 0, enabledAt: null },
adminIds,
userPolicies,
nowMs
)).toBe(true)
})
it('does not delete other users images', () => {
const userPolicies = new Map([[2, { days: 30, enabledAt }]])
expect(shouldAutoDeleteImage(
image(3, '2026-07-01T00:00:00.000Z'),
{ days: 0, enabledAt: null },
adminIds,
userPolicies,
nowMs
)).toBe(false)
})
it('applies admin global policy only to admin or legacy images', () => {
const globalPolicy = { days: 30, enabledAt }
const userPolicies = new Map<number, { days: number, enabledAt: string | null }>()
expect(shouldAutoDeleteImage(
image(2, '2026-07-01T00:00:00.000Z'),
globalPolicy,
adminIds,
userPolicies,
nowMs
)).toBe(false)
expect(shouldAutoDeleteImage(
image(1, '2026-07-01T00:00:00.000Z'),
globalPolicy,
adminIds,
userPolicies,
nowMs
)).toBe(false)
expect(shouldAutoDeleteImage(
image(1, '2026-08-05T00:00:00.000Z'),
globalPolicy,
adminIds,
userPolicies,
nowMs
)).toBe(true)
expect(shouldAutoDeleteImage(
image(null, '2026-08-05T00:00:00.000Z'),
globalPolicy,
adminIds,
userPolicies,
nowMs
)).toBe(true)
})
it('keeps recent images', () => {
const userPolicies = new Map([[2, { days: 30, enabledAt }]])
expect(shouldAutoDeleteImage(
image(2, '2026-09-10T00:00:00.000Z'),
{ days: 0, enabledAt: null },
adminIds,
userPolicies,
nowMs
)).toBe(false)
})
})
+110
View File
@@ -0,0 +1,110 @@
import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type AutoDeletePolicy } from './db'
import { getGlobalAutoDeletePolicy } from './env'
import { deleteImage, headImage, listImageKeys, type StoredImage } from './storage'
import { logInfo, logWarn } from './logger'
export interface AutoDeleteResult {
deleted: number
failed: number
skipped: boolean
days: number
}
const MS_PER_DAY = 24 * 60 * 60 * 1000
function imageAgeDays(uploadedMs: number, nowMs: number): number {
return (nowMs - uploadedMs) / MS_PER_DAY
}
function policyMatches(
uploadedMs: number,
nowMs: number,
policy: AutoDeletePolicy
): boolean {
if (policy.days <= 0) return false
if (policy.enabledAt) {
const enabledMs = new Date(policy.enabledAt).getTime()
if (!Number.isFinite(enabledMs) || uploadedMs < enabledMs) {
return false
}
}
return imageAgeDays(uploadedMs, nowMs) >= policy.days
}
export function shouldAutoDeleteImage(
image: StoredImage,
globalPolicy: AutoDeletePolicy,
adminUserIds: Set<number>,
userPolicies: Map<number, AutoDeletePolicy>,
nowMs = Date.now()
): boolean {
const uploadedMs = new Date(image.uploadedAt).getTime()
if (!Number.isFinite(uploadedMs) || uploadedMs > nowMs) {
return false
}
const ownerId = image.userId ?? null
if (ownerId != null) {
const userPolicy = userPolicies.get(ownerId)
if (userPolicy && policyMatches(uploadedMs, nowMs, userPolicy)) {
return true
}
if (adminUserIds.has(ownerId) && policyMatches(uploadedMs, nowMs, globalPolicy)) {
return true
}
return false
}
return policyMatches(uploadedMs, nowMs, globalPolicy)
}
export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
const globalPolicy = getGlobalAutoDeletePolicy()
const userPolicies = listUserAutoDeletePolicies()
const adminUserIds = listAdminUserIds()
if (globalPolicy.days <= 0 && userPolicies.size === 0) {
return { deleted: 0, failed: 0, skipped: true, days: 0 }
}
const nowMs = Date.now()
const keys = await listImageKeys()
let deleted = 0
let failed = 0
for (const key of keys) {
const image = await headImage(key)
if (!image) continue
if (!shouldAutoDeleteImage(image, globalPolicy, adminUserIds, userPolicies, nowMs)) {
continue
}
try {
await deleteImage(key)
insertActivityLog({
action: 'delete',
key,
originalName: image.originalName,
size: image.size,
contentType: image.contentType,
source: 'web'
})
deleted++
} catch (error) {
logWarn('auto-delete failed', { key, error })
failed++
}
}
if (deleted > 0 || failed > 0) {
logInfo('auto-delete cleanup finished', { globalDays: globalPolicy.days, deleted, failed })
}
return { deleted, failed, skipped: false, days: globalPolicy.days }
}
+19
View File
@@ -0,0 +1,19 @@
export const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/svg+xml',
'image/x-icon'
] as const
export type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number]
export const MAX_FILE_SIZE = 10 * 1024 * 1024
export const MAX_FILES_PER_UPLOAD = 10
export const MAX_DELETE_BATCH = 100
export const DEFAULT_LIST_LIMIT = 30
export const MAX_LIST_LIMIT = 100
export const IMAGE_CACHE_CONTROL
= 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'
+570
View File
@@ -0,0 +1,570 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { getDataDir } from './storage'
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
export interface ActivityLogRow {
id: number
action: LogAction
key: string
original_name: string
size: number
content_type: string
source: LogSource
created_at: string
}
export interface ActivityLogInput {
action: LogAction
key: string
originalName: string
size: number
contentType: string
source: LogSource
createdAt?: string
}
let db: DatabaseSync | null = null
function getDb(): DatabaseSync {
if (db) return db
const dataDir = getDataDir()
mkdirSync(dataDir, { recursive: true })
const path = join(dataDir, 'pichost.db')
db = new DatabaseSync(path)
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS activity_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL CHECK(action IN ('upload', 'delete')),
key TEXT NOT NULL,
original_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
content_type TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK(source IN ('web', 'api', 'twikoo')),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
ON activity_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_logs_action_created
ON activity_logs(action, created_at DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
`)
migrateSchema(db)
return db
}
function migrateSchema(database: DatabaseSync): void {
const columns = database.prepare('PRAGMA table_info(users)').all() as Array<{
name: string
}>
if (!columns.some(column => column.name === 'api_token')) {
database.exec('ALTER TABLE users ADD COLUMN api_token TEXT')
database.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_token ON users(api_token) WHERE api_token IS NOT NULL'
)
}
if (!columns.some(column => column.name === 'auto_delete_days')) {
database.exec('ALTER TABLE users ADD COLUMN auto_delete_days INTEGER NOT NULL DEFAULT 0')
}
const userColumns = database.prepare('PRAGMA table_info(users)').all() as Array<{
name: string
}>
if (!userColumns.some(column => column.name === 'auto_delete_enabled_at')) {
database.exec('ALTER TABLE users ADD COLUMN auto_delete_enabled_at TEXT')
database.exec(`
UPDATE users
SET auto_delete_enabled_at = datetime('now')
WHERE auto_delete_days > 0 AND auto_delete_enabled_at IS NULL
`)
}
const daysRow = database.prepare(`
SELECT value FROM settings WHERE key = 'auto_delete_days'
`).get() as { value: string } | undefined
const enabledAtRow = database.prepare(`
SELECT value FROM settings WHERE key = 'auto_delete_enabled_at'
`).get() as { value: string } | undefined
const globalDays = daysRow ? Number(daysRow.value) : 0
if (globalDays > 0 && !enabledAtRow) {
const now = new Date().toISOString()
database.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES ('auto_delete_enabled_at', ?, ?)
`).run(now, now)
}
}
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
export type UserRole = 'admin' | 'user'
export interface UserRow {
id: number
username: string
password_hash: string
role: UserRole
created_at: string
api_token: string | null
auto_delete_days: number
auto_delete_enabled_at: string | null
}
export interface AutoDeletePolicy {
days: number
enabledAt: string | null
}
export interface SessionRow {
id: string
user_id: number
token_hash: string
expires_at: string
created_at: string
}
export function countUsers(): number {
const row = getDb().prepare('SELECT COUNT(*) AS count FROM users').get() as {
count: number
}
return row.count
}
export function listUserIdUsernameMap(): Map<number, string> {
const rows = getDb().prepare(`
SELECT id, username
FROM users
`).all() as Array<{ id: number, username: string }>
return new Map(rows.map(row => [row.id, row.username]))
}
export function findUserById(id: number): UserRow | null {
const row = getDb().prepare(`
SELECT ${USER_COLUMNS}
FROM users
WHERE id = ?
`).get(id) as UserRow | undefined
return row ?? null
}
export function findUserByUsername(username: string): UserRow | null {
const row = getDb().prepare(`
SELECT ${USER_COLUMNS}
FROM users
WHERE username = ?
`).get(username) as UserRow | undefined
return row ?? null
}
export function findUserByApiToken(token: string): UserRow | null {
if (!token) return null
const row = getDb().prepare(`
SELECT ${USER_COLUMNS}
FROM users
WHERE api_token = ?
`).get(token) as UserRow | undefined
return row ?? null
}
export function getUserApiToken(userId: number): string | null {
const row = getDb().prepare(`
SELECT api_token
FROM users
WHERE id = ?
`).get(userId) as { api_token: string | null } | undefined
return row?.api_token ?? null
}
export function setUserApiToken(userId: number, token: string): void {
getDb().prepare(`
UPDATE users
SET api_token = ?
WHERE id = ?
`).run(token, userId)
}
export function getUserAutoDeletePolicy(userId: number): AutoDeletePolicy {
const row = getDb().prepare(`
SELECT auto_delete_days, auto_delete_enabled_at
FROM users
WHERE id = ?
`).get(userId) as { auto_delete_days: number, auto_delete_enabled_at: string | null } | undefined
return {
days: row?.auto_delete_days ?? 0,
enabledAt: row?.auto_delete_enabled_at ?? null
}
}
export function getUserAutoDeleteDays(userId: number): number {
return getUserAutoDeletePolicy(userId).days
}
export function setUserAutoDeletePolicy(userId: number, days: number): void {
const current = getUserAutoDeletePolicy(userId)
const now = new Date().toISOString()
let enabledAt = current.enabledAt
if (days > 0 && current.days === 0) {
enabledAt = now
} else if (days === 0) {
enabledAt = null
}
getDb().prepare(`
UPDATE users
SET auto_delete_days = ?, auto_delete_enabled_at = ?
WHERE id = ?
`).run(days, enabledAt, userId)
}
export function listUserAutoDeletePolicies(): Map<number, AutoDeletePolicy> {
const rows = getDb().prepare(`
SELECT id, auto_delete_days, auto_delete_enabled_at
FROM users
WHERE auto_delete_days > 0
`).all() as Array<{
id: number
auto_delete_days: number
auto_delete_enabled_at: string | null
}>
return new Map(rows.map(row => [
row.id,
{ days: row.auto_delete_days, enabledAt: row.auto_delete_enabled_at }
]))
}
export function listAdminUserIds(): Set<number> {
const rows = getDb().prepare(`
SELECT id
FROM users
WHERE role = 'admin'
`).all() as Array<{ id: number }>
return new Set(rows.map(row => row.id))
}
export function setUserAutoDeleteDays(userId: number, days: number): void {
setUserAutoDeletePolicy(userId, days)
}
export function listUserAutoDeleteDays(): Map<number, number> {
const policies = listUserAutoDeletePolicies()
return new Map([...policies.entries()].map(([id, policy]) => [id, policy.days]))
}
export function updateUserPassword(userId: number, passwordHash: string): void {
getDb().prepare(`
UPDATE users
SET password_hash = ?
WHERE id = ?
`).run(passwordHash, userId)
}
export function ensureUserApiToken(userId: number): string {
const existing = getUserApiToken(userId)
if (existing) return existing
const token = generateApiUploadToken()
setUserApiToken(userId, token)
return token
}
export function insertUser(input: {
username: string
passwordHash: string
role: UserRole
createdAt: string
}): number {
const result = getDb().prepare(`
INSERT INTO users (username, password_hash, role, created_at)
VALUES (?, ?, ?, ?)
`).run(input.username, input.passwordHash, input.role, input.createdAt)
return Number(result.lastInsertRowid)
}
export function createSessionRow(input: {
id: string
userId: number
tokenHash: string
expiresAt: string
createdAt: string
}): void {
getDb().prepare(`
INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(
input.id,
input.userId,
input.tokenHash,
input.expiresAt,
input.createdAt
)
}
export function findSessionById(id: string): SessionRow | null {
const row = getDb().prepare(`
SELECT id, user_id, token_hash, expires_at, created_at
FROM sessions
WHERE id = ?
`).get(id) as SessionRow | undefined
return row ?? null
}
export function deleteSession(id: string): void {
getDb().prepare('DELETE FROM sessions WHERE id = ?').run(id)
}
export function deleteSessionsForUser(userId: number): void {
getDb().prepare('DELETE FROM sessions WHERE user_id = ?').run(userId)
}
export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
export const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
export function isAllowRegistration(): boolean {
return getSetting(SETTINGS_ALLOW_REGISTRATION) === 'true'
}
export function setAllowRegistration(allow: boolean): void {
setSetting(SETTINGS_ALLOW_REGISTRATION, allow ? 'true' : 'false')
}
export function getSetting(key: string): string | null {
const row = getDb().prepare(`
SELECT value
FROM settings
WHERE key = ?
`).get(key) as { value: string } | undefined
return row?.value ?? null
}
export function setSetting(key: string, value: string): void {
const updatedAt = new Date().toISOString()
getDb().prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
`).run(key, value, updatedAt)
}
export function generateApiUploadToken(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
}
export function insertActivityLog(input: ActivityLogInput): void {
try {
const createdAt = input.createdAt ?? new Date().toISOString()
getDb().prepare(`
INSERT INTO activity_logs
(action, key, original_name, size, content_type, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
input.action,
input.key,
input.originalName,
input.size,
input.contentType,
input.source,
createdAt
)
} catch (error) {
console.error('[activity-log] insert failed', error)
}
}
export function listActivityLogs(options: {
limit: number
page?: number
action?: LogAction
source?: LogSource
folder?: string
}): {
items: ActivityLogRow[]
total: number
page: number
pageSize: number
totalPages: number
} {
const database = getDb()
const pageSize = options.limit
const params: Array<string | number> = []
const where: string[] = []
if (options.action) {
where.push('action = ?')
params.push(options.action)
}
if (options.source) {
where.push('source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('key LIKE ?')
params.push(`${options.folder}/%`)
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
const countRow = database.prepare(`
SELECT COUNT(*) AS count
FROM activity_logs
${whereSql}
`).get(...params) as { count: number }
const total = countRow.count
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
const requestedPage = Math.max(1, options.page ?? 1)
const safePage = Math.min(requestedPage, totalPages)
const offset = (safePage - 1) * pageSize
const rows = database.prepare(`
SELECT id, action, key, original_name, size, content_type, source, created_at
FROM activity_logs
${whereSql}
ORDER BY id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, offset) as unknown as ActivityLogRow[]
return {
items: rows,
total,
page: safePage,
pageSize,
totalPages
}
}
function countSince(action: LogAction, sinceIso: string): number {
const row = getDb().prepare(`
SELECT COUNT(*) AS total
FROM activity_logs
WHERE action = ? AND created_at >= ?
`).get(action, sinceIso) as unknown as { total: number } | undefined
return row?.total ?? 0
}
function sumUploadBytes(): number {
const row = getDb().prepare(`
SELECT COALESCE(SUM(size), 0) AS total
FROM activity_logs
WHERE action = 'upload'
`).get() as unknown as { total: number } | undefined
return row?.total ?? 0
}
function countByUploadFolder(): Array<{ folder: string, count: number }> {
const rows = getDb().prepare(`
SELECT
CASE
WHEN instr(key, '/') > 0 THEN substr(key, 1, instr(key, '/') - 1)
ELSE '(root)'
END AS folder,
COUNT(*) AS total
FROM activity_logs
WHERE action = 'upload'
GROUP BY folder
ORDER BY total DESC, folder ASC
`).all() as unknown as Array<{ folder: string, total: number }>
return rows.map(row => ({ folder: row.folder, count: row.total }))
}
function countBySource(): Record<LogSource, number> {
const rows = getDb().prepare(`
SELECT source, COUNT(*) AS total
FROM activity_logs
WHERE action = 'upload'
GROUP BY source
`).all() as unknown as Array<{ source: LogSource, total: number }>
const result: Record<LogSource, number> = { web: 0, api: 0, twikoo: 0 }
for (const row of rows) {
result[row.source] = row.total
}
return result
}
function startOfDayIsoInShanghai(now = new Date()): string {
// Asia/Shanghai = UTC+8,没有夏令时
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
const d = shifted.getUTCDate()
return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
}
function startOfMonthIsoInShanghai(now = new Date()): string {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
}
export function getActivityStats(): {
uploadToday: number
uploadMonth: number
deleteToday: number
deleteMonth: number
uploadTotal: number
deleteTotal: number
uploadBytesTotal: number
bySource: Record<LogSource, number>
byFolderUploads: Array<{ folder: string, count: number }>
} {
const startOfDay = startOfDayIsoInShanghai()
const startOfMonth = startOfMonthIsoInShanghai()
const uploadTotalRow = getDb().prepare(`
SELECT COUNT(*) AS total FROM activity_logs WHERE action = 'upload'
`).get() as unknown as { total: number }
const deleteTotalRow = getDb().prepare(`
SELECT COUNT(*) AS total FROM activity_logs WHERE action = 'delete'
`).get() as unknown as { total: number }
return {
uploadToday: countSince('upload', startOfDay),
uploadMonth: countSince('upload', startOfMonth),
deleteToday: countSince('delete', startOfDay),
deleteMonth: countSince('delete', startOfMonth),
uploadTotal: uploadTotalRow.total,
deleteTotal: deleteTotalRow.total,
uploadBytesTotal: sumUploadBytes(),
bySource: countBySource(),
byFolderUploads: countByUploadFolder()
}
}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more