release: PicHost v1.0.0
Multi-user image hosting with RBAC, upload preferences, and admin settings.
No files matched your search
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.output
|
||||
.nuxt
|
||||
.git
|
||||
.github
|
||||
data
|
||||
dist
|
||||
.env
|
||||
*.md
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.{ts,vue,js,mjs,cjs,json,jsonc,yml,yaml,md,css} text eol=lf
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| Upload preferences | Stats & gallery |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| Settings | |
|
||||
|:---:|:---:|
|
||||
|  | |
|
||||
|
||||
## 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)
|
||||
@@ -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>
|
||||
|
||||
---
|
||||
|
||||
## 界面预览
|
||||
|
||||
| 登录 | 上传 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 上传偏好 | 统计与图库 |
|
||||
|:---:|:---:|
|
||||
|  |  |
|
||||
|
||||
| 系统设置 | |
|
||||
|:---:|:---:|
|
||||
|  | |
|
||||
|
||||
## 特性
|
||||
|
||||
- **拖拽 / 点击 / 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)
|
||||
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'green',
|
||||
neutral: 'slate'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'PicHost - 个人图床',
|
||||
meta: [
|
||||
{ name: 'description', content: '个人自用的轻量图床系统' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<NuxtPage />
|
||||
</UApp>
|
||||
</template>
|
||||
@@ -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);
|
||||
}
|
||||
|
After Width: | Height: | Size: 506 KiB |
|
After Width: | Height: | Size: 546 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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: <token></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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,6 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
)
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": [
|
||||
"github>nuxt/renovate-config-nuxt"
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [{
|
||||
"matchDepTypes": ["resolutions"],
|
||||
"enabled": false
|
||||
}]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
return {
|
||||
status: 'ok',
|
||||
version: config.appVersion
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||