13 Commits
Author SHA1 Message Date
O96u e9f1cde06c fix: sync package-lock.json for vitepress devDependency
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
npm ci failed in CI because vitepress was added to package.json without
updating the lockfile.
2026-08-29 11:46:56 +08:00
O96u ff20f97522 release: v1.2.1 — login captcha, docs site, and bug fixes
Add slider captcha for web login, VitePress documentation on GitHub Pages,
and fixes for gallery delete refresh, pagination jump, and copy-format persistence.
2026-08-29 11:42:18 +08:00
O96u 469d3be735 release: v1.2.0 — unified images/ storage and legacy migration
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Consolidate local images under data/images/, add docker migrate CLI,
startup index sync with orphan cleanup, and remove upload folder UI/API.
2026-08-28 17:18:03 +08:00
O96u 3dee0437e6 release: v1.1.5 — gallery preview reliability fixes
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Bump version to 1.1.5. Retry gallery thumbnails after transient failures,
verify storage before serving, and stop caching image 404/403 responses.
2026-08-28 14:25:40 +08:00
O96u f25c89855f fix: gallery preview retries and avoid caching failed image responses
Verify storage files before serving from the index, disable CDN caching
on image 404/403 and host-isolation blocks, and retry gallery thumbnails
after transient load failures.
2026-08-28 14:25:04 +08:00
O96u b0a5fbfca5 release: v1.1.4 — storage path layout and bidirectional domain isolation
Bump version to 1.1.4. Add flat vs date storage paths, basename-only
hide-folder URLs, and block image paths on the site host when domain
separation is active.
2026-08-28 09:59:13 +08:00
O96u 438c1d5529 feat: storage path layout, basename URLs, and bidirectional domain isolation
Add storage_use_date_path for flat vs date-key storage layout and emit
basename-only public URLs when hide-folder is enabled. Block image paths
on the site host when domain separation is active so only the image host
serves files. Update settings UI, i18n, tests, and deployment docs.
2026-08-28 09:56:45 +08:00
O96u Build f7488753ca Update LICENSE 2026-08-27 15:32:31 +08:00
O96u d8c1303c32 release: v1.1.3 — dual-domain separation and URL options
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Add admin/image domain separation with host isolation middleware,
optional hide-folder public URLs, GitHub release update check, and
Lucky/Nginx deployment docs. Switch license to GPL-3.0-only.
2026-08-27 12:13:58 +08:00
O96u 55effa204e release: v1.1.2 — activity logs, storage filter, upload rate limits
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
Bump version and changelog; fix lint issues before release.
2026-08-26 17:14:50 +08:00
O96u 6fc054328d feat: activity logs, gallery storage filter, and upload rate limits
Add per-user activity logs with storage backend tracking, gallery
storage filter, and IP/token upload rate limiting. Fix storage delete
modal flash, pin local backend in filter order, and link settings
version to GitHub releases.
2026-08-26 17:09:38 +08:00
O96u 901fefb3f5 release: v1.1.1 — session fixes, storage cleanup, logging
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
2026-08-26 15:38:56 +08:00
O96u acc64751ef release: v1.1.0 — S3 multi-backend storage and admin UI
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
2026-08-26 14:49:46 +08:00
153 changed files with 13077 additions and 1758 deletions

No files matched your search

+11 -1
View File
@@ -11,7 +11,17 @@
# 可选;不填可在设置页配置。若在此填写则覆盖后台设置 # 可选;不填可在设置页配置。若在此填写则覆盖后台设置
# ALLOWED_REFERER_HOSTS=example.com,www.example.com # ALLOWED_REFERER_HOSTS=example.com,www.example.com
# 图片直链域名(可选;不填则使用请求域名,也可在设置页配置) # 管理后台域名(可选;不填则使用当前请求域名,也可在设置页配置)
# SITE_BASE_URL=https://admin.example.com
# 隐藏图片直链中的目录前缀(如 images/2026/08/a.webp → a.webp)
# HIDE_FOLDER_IN_URL=false
# 上传目录按年/月分组(true/date 默认;false/flat 则保存为 images/xxx.webp)
# STORAGE_USE_DATE_PATH=true
# STORAGE_LAYOUT=date
# 图片直链域名(可选;不填则使用当前请求域名,也可在设置页配置)
# IMAGE_BASE_URL=https://pic.example.com # IMAGE_BASE_URL=https://pic.example.com
# 图片存储目录(默认 ./data,容器内为 /data) # 图片存储目录(默认 ./data,容器内为 /data)
+3
View File
@@ -37,3 +37,6 @@ jobs:
- name: Test - name: Test
run: npm run test run: npm run test
- name: Build docs
run: npm run docs:build
+59
View File
@@ -0,0 +1,59 @@
name: docs
on:
push:
branches: [main]
paths:
- 'docs-site/**'
- 'docs/screenshots/**'
- '.github/workflows/docs.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install npm 11
run: |
npm install -g npm@11.12.1
npm --version
- name: Install dependencies
run: npm ci
- name: Build docs
run: npm run docs:build
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: docs-site/.vitepress/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+7 -2
View File
@@ -37,6 +37,11 @@ data
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库) # 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
git-hooks/ git-hooks/
# 飞牛 fpk 工程与产物(仅本地维护,不进仓库) # 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库)
scripts/fpk/ scripts/
dist-fpk/ dist-fpk/
# VitePress docs site
docs-site/.vitepress/cache
docs-site/.vitepress/dist
docs-site/public/screenshots
+137 -1
View File
@@ -2,6 +2,137 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [Unreleased]
## [1.2.1] - 2026-08-29
### 新增
- 登录滑动拼图验证(缺口对齐);验证与登录分两步,登录接口校验 `captchaId` / `captchaPosition`
- VitePress 文档站(`docs-site/`):中英文指南,部署至 GitHub Pages <https://o96u.github.io/PicHost/>
- `npm run docs:dev` / `docs:build` / `docs:preview`;CI 增加 `docs:build`;`.github/workflows/docs.yml` 发布文档
### 修复
- 修复统计页删除图片后图库不刷新的问题
- 修复分页跳转输入框在 `type="number"` 下无法跳转的问题
- 修复非首页路由下复制链接格式偏好不持久化的问题(`useUploadPreferences` 首次使用时自动加载)
- 滑动验证 UI:滑块垂直居中、缺口尺寸与滑块一致
### 改进
- README 精简为产品宣传页,详细说明指向文档站
- 双域名文档补充:未知 Host 风险、Cloudflare 橙云部署、勿用 Pages/Workers 整站反代
- 文档截图更新(登录页等);旧 `docs/*.md` 长文迁入 `docs-site/`
## [1.2.0] - 2026-08-28
### 变更
- 图片统一存放在 `data/images/`;遗留 `blog/`、`twikoo/` 等并列目录需迁移后升级
### 新增
- CLI 迁移工具:`docker exec pichost migrate` / `migrate --apply`(`server/cli/migrate-to-single-images.mjs`)
- 扫描 `data/` 下除 `images` 外所有顶层目录中的图片,输出 `data/mapping.json`
- 启动时自动同步图片索引(扫描磁盘、归一化遗留 key、清理孤儿记录),并打印同步日志
- 迁移文档:[文档站 · v1.2 迁移](https://o96u.github.io/PicHost/guide/migration)
### 改进
- 启动时归一化遗留 SQLite key、清理无文件的孤儿索引;支持删除仅索引无文件的记录
## [1.1.5] - 2026-08-28
### 修复
- 修复 API 上传后图库预览偶发失败、需等待刷新才显示的问题
- 图库卡片加载失败后自动重试,刷新列表时重置预览状态
### 改进
- 出图前校验存储文件实际存在,避免索引与磁盘短暂不一致
- 图片 404/403 与域名隔离 404 禁止 CDN 缓存,降低反代误判影响
## [1.1.4] - 2026-08-28
### 新增
- 上传目录可按年/月分组或扁平保存(`storage_use_date_path` / `STORAGE_USE_DATE_PATH`),设置页可切换
- 双域名分离下网站域禁止直链出图,仅图片域可访问图片路径
### 改进
- 隐藏 `images/` 前缀时外链改为仅文件名(如 `xxx.webp`);图片域仍兼容旧版 `/2026/08/xxx.webp` 短链
- 设置页重构:功能开关、域名配置、存储路径分组展示
- 本地磁盘索引扫描支持扁平路径;文件夹列表 `images` 置顶
## [1.1.3] - 2026-08-27
### 新增
- 后台与图片域名分离(`SITE_BASE_URL` / `IMAGE_BASE_URL`):单实例双域名,Host 中间件隔离图片域非图片路径
- `/setup` 与系统设置支持启用/关闭域名分离,设置页可后续关闭并恢复单域
- 可选隐藏外链中的 `images/` 前缀(多文件夹时确认提示)
- 设置页加载时检测 GitHub 最新版本并提示更新
- 双域名部署文档(Nginx / Lucky / 本地 hosts 测试):[文档站 · 双域名分离](https://o96u.github.io/PicHost/guide/domain-separation)
### 改进
- 双域名下防盗链自动放行网站域与图片域主机名
- 开发服务器默认绑定 `127.0.0.1:3000`,便于 hosts 双域本地测试
- API 文档补充自定义文件夹的 cURL 示例
### 其他
- 许可证变更为 GPL-3.0-only
- `scripts/` 目录改为本地维护,不再纳入仓库
## [1.1.2] - 2026-08-26
### 新增
- 操作日志页(`/logs`):记录上传/删除,管理员可查看全部用户并筛选
- 图库记录支持按存储后端筛选
- 上传 IP / API Token 限流(15 分钟窗口)
### 改进
- 操作日志记录存储后端;图库卡片可展示存储来源
- 存储筛选下拉固定「本地磁盘」排在第二位
- 系统设置页版本号链接至 GitHub Releases
### 修复
- 修复删除存储后端后确认弹窗闪出空名称
## [1.1.1] - 2026-08-26
### 修复
- 移除启动时自动创建的空白「S3 兼容存储」占位;升级后自动清理旧占位记录
- 修复管理页切换路由时反复闪现「正在验证登录状态」
- 修复静默会话校验后设置 / 统计 / API 页不加载数据
### 改进
- Docker 日志:4xx/5xx 分级输出,记录错误原因与堆栈;上传/启动任务失败写入详细错误
## [1.1.0] - 2026-08-26
### 新增
- S3 兼容对象存储后端(R2 / AWS S3 / 腾讯云 COS / 阿里云 OSS)
- `storage_backends` + `images` 索引表,列表/搜索/统计改查 SQLite
- 混合直链:`proxy`(默认,PicHost 代理)与 `public`(302 到 CDN)
- 管理员 `/storage` 存储管理页:多后端卡片、用量条、添加/编辑/删除云存储实例
- 可选环境变量覆盖存储配置(`STORAGE_BACKEND`、`S3_*`)
### 改进
- 启动时自动将本地磁盘图片扫描迁入 `images` 索引(幂等)
- 上传/删除/直链/auto-delete 按 `backend_id` 路由到对应后端
## [1.0.4] - 2026-08-25 ## [1.0.4] - 2026-08-25
### 新增 ### 新增
@@ -32,6 +163,7 @@
- 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单 - 中英文界面(`@nuxtjs/i18n`),语言切换与主题切换独立菜单
- 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户) - 移动端导航:汉堡菜单 + 分组用户菜单(语言 / 外观 / 账户)
- Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`) - Docker 忘记密码:`docker exec pichost reset-password`(随机密码,见 `server/cli/`)
- Docker 升级到 v1.2.0 前迁移遗留目录:`docker exec pichost migrate` / `migrate --apply`
### 改进 ### 改进
@@ -68,8 +200,12 @@
### 路线图 ### 路线图
- **v1.1.0**(计划中):S3、Cloudflare R2 及更多对象存储后端 - **v1.2.0**(开发中):统一 `images/` 存储、遗留目录迁移 CLI、启动索引同步
[1.2.0]: https://github.com/O96u/PicHost/releases/tag/v1.2.0
[1.1.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1
[1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0
[1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4
[1.0.3]: https://github.com/O96u/PicHost/releases/tag/v1.0.3 [1.0.3]: https://github.com/O96u/PicHost/releases/tag/v1.0.3
[1.0.2]: https://github.com/O96u/PicHost/releases/tag/v1.0.2 [1.0.2]: https://github.com/O96u/PicHost/releases/tag/v1.0.2
[1.0.1]: https://github.com/O96u/PicHost/releases/tag/v1.0.1 [1.0.1]: https://github.com/O96u/PicHost/releases/tag/v1.0.1
+1
View File
@@ -24,6 +24,7 @@ ENV NODE_ENV=production \
COPY --from=build /app/.output ./.output COPY --from=build /app/.output ./.output
COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs COPY server/cli/reset-password.mjs ./server/cli/reset-password.mjs
COPY server/cli/migrate-to-single-images.mjs ./server/cli/migrate-to-single-images.mjs
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh
+670 -17
View File
@@ -1,21 +1,674 @@
MIT License
Copyright (c) 2026 O96u GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Permission is hereby granted, free of charge, to any person obtaining a copy Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
of this software and associated documentation files (the "Software"), to deal Everyone is permitted to copy and distribute verbatim copies
in the Software without restriction, including without limitation the rights of this license document, but changing it is not allowed.
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 Preamble
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR The GNU General Public License is a free, copyleft license for
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, software and other kinds of works.
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER The licenses for most software and other practical works are designed
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, to take away your freedom to share and change the works. By contrast,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE the GNU General Public License is intended to guarantee your freedom to
SOFTWARE. share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
PicHost - Self-hosted image hosting
Copyright (C) 2026 O96u
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License version 3 as
published by the Free Software Foundation.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+24 -157
View File
@@ -6,22 +6,21 @@
<p align="center"><strong>Lightweight personal image hosting</strong> · Simple uploads, clean management</p> <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">Self-hosted / Multi-user / Docker / API / Twikoo · Local disk or object storage</p>
<p align="center"> <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/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a> <a href="https://github.com/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" /> <img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a> <a href="https://hub.docker.com/r/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" /> <img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p> </p>
<p align="center"> <p align="center">
<a href="#quick-start">Quick Start</a> · <a href="https://o96u.github.io/PicHost/en/">Docs</a> ·
<a href="#screenshots">Screenshots</a> · <a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> · <a href="#features">Features</a> ·
<a href="#tech-stack">Tech Stack</a> · <a href="#quick-start">Quick Start</a> ·
<a href="#roadmap">Roadmap</a> ·
<a href="README.md">简体中文</a> <a href="README.md">简体中文</a>
</p> </p>
@@ -29,34 +28,27 @@
## Screenshots ## Screenshots
| Login | Upload | **main** has no public demo. **cloudflare** branch live demo: [pic.roven.cc](https://pic.roven.cc)
| :----------------------------------: | :------------------------------------: |
| ![Login](docs/screenshots/login.png) | ![Upload](docs/screenshots/upload.png) |
| Upload preferences | Stats & gallery | | Home | Storage |
| :----------------------------------------------: | :----------------------------------: | | :--: | :-----: |
| ![Preferences](docs/screenshots/preferences.png) | ![Stats](docs/screenshots/stats.png) | | ![Home](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) |
| Settings | | | Stats | Settings |
| :----------------------------------------: | :-: | | :---: | :-------: |
| ![Settings](docs/screenshots/settings.png) | | | ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings-en.png) |
## Features ## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths - **Drag, click, or Ctrl+V paste** — server-side WebP, Referer hotlink protection
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL - **Multi-user** — login with slider captcha, optional registration; users see only their images
- **Multi-user** — username/password login; optional registration; users see only their images - **Multi-backend storage** — local disk + S3-compatible (R2 / COS / OSS / AWS); hybrid `proxy` / `public` URLs
- **Upload preferences** (card flip UI): client-side compression, auto-copy links, per-user auto-delete - **Gallery & stats** — browse, search, batch delete; upload trends and source breakdown
- **Gallery** — browse, search, batch delete; admin sees uploader badges - **API & Twikoo** — global / per-user tokens; `POST /api/index.php` compatible
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count - **Zero-config Docker** — first-run web wizard, no secrets upfront
- **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 ## Quick Start
### Docker (recommended)
```bash ```bash
docker run -d \ docker run -d \
--name pichost \ --name pichost \
@@ -66,143 +58,18 @@ docker run -d \
muxui/pichost:latest muxui/pichost:latest
``` ```
Default port: **6892**. Open `http://<host>:6892` in your browser and complete the setup wizard. Open `http://<host>:6892` and complete the setup wizard.
**Forgot password** (requires `docker exec` / server access): **Full guide** (env vars, upgrades, dual-domain setup, API, Twikoo, reverse proxy, local dev, etc.): **[Documentation](https://o96u.github.io/PicHost/en/)**.
```bash | Branch | Notes |
docker exec pichost reset-password # admin only, when there is exactly one | ------ | ----- |
docker exec pichost reset-password <username> # admin or regular user | [**main**](https://github.com/O96u/PicHost/tree/main) | Default: multi-backend, dual-domain, unified `images/` storage |
``` | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | Cloudflare R2 only; live demo [pic.roven.cc](https://pic.roven.cc) |
A random password is printed. Fails if the user does not exist.
If you cloned the repo, you can also use `docker compose up -d` (see `docker-compose.yml`).
### Local development
```bash
npm install
cp .env.example .env # optional
npm run dev
```
Open `http://localhost:3000/setup` to create the admin account. Data defaults to `./data`.
### 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.3** | CI and Docker multi-arch build fixes (current) |
| **v1.0.2** | i18n, mobile menus, stats/upload UI, Docker password reset |
| **v1.0.1** | Settings version display, mobile upload preferences layout fix |
| **v1.0.0** | Local storage, multi-user, API, Twikoo |
| **v1.1.0** | S3, Cloudflare R2, and more object storage backends |
## License ## License
[MIT](LICENSE) [GPL-3.0](LICENSE)
## Friend links ## Friend links
+24 -162
View File
@@ -6,22 +6,21 @@
<p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p> <p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p>
<p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 零配置引导</p> <p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 本地磁盘或对象存储</p>
<p align="center"> <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/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a> <a href="https://github.com/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" /> <img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a> <a href="https://hub.docker.com/r/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" /> <img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p> </p>
<p align="center"> <p align="center">
<a href="#快速开始">快速开始</a> · <a href="https://o96u.github.io/PicHost/">文档</a> ·
<a href="#界面预览">预览</a> · <a href="#界面预览">预览</a> ·
<a href="#特性">特性</a> · <a href="#特性">特性</a> ·
<a href="#技术栈">技术栈</a> · <a href="#快速开始">快速开始</a> ·
<a href="#路线图">路线图</a> ·
<a href="README.en.md">English</a> <a href="README.en.md">English</a>
</p> </p>
@@ -29,34 +28,27 @@
## 界面预览 ## 界面预览
| 登录 | 上传 | **main** 分支暂无公网演示;**cloudflare** 分支在线体验:[pic.roven.cc](https://pic.roven.cc)
| :---------------------------------: | :----------------------------------: |
| ![登录](docs/screenshots/login.png) | ![上传](docs/screenshots/upload.png) |
| 上传偏好 | 统计与图库 | | 首页 | 存储 |
| :-------------------------------------------: | :---------------------------------: | | :--: | :--: |
| ![上传偏好](docs/screenshots/preferences.png) | ![统计](docs/screenshots/stats.png) | | ![首页](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) |
| 系统设置 | | | 统计 | 设置 |
| :------------------------------------: | :-: | | :--: | :--: |
| ![设置](docs/screenshots/settings.png) | | | ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) |
## 特性 ## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录 - **拖拽 / 点击 / Ctrl+V 粘贴**上传;服务端 WebP 压缩、Referer 防盗链
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名 - **多用户**:账号密码登录(滑动验证)、可选开放注册;普通用户仅见自己的图片
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片 - **多后端存储**:本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);混合直链 `proxy` / `public`
- **上传偏好**(卡片翻转设置):客户端预压缩、自动复制链接、按用户自动删除 - **图库与统计**:浏览、搜索、批量删除;上传趋势与来源分布
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签 - **API 与 Twikoo**:全局 / 个人 Token;`POST /api/index.php` 兼容
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量 - **Docker 零配置**:首次访问 Web 引导创建管理员
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
- **Twikoo**:`POST /api/index.php`(`image` + `token`)
- **Docker 零配置**:首次访问 Web 引导创建管理员,无需预先写密钥
## 快速开始 ## 快速开始
### Docker(推荐)
```bash ```bash
docker run -d \ docker run -d \
--name pichost \ --name pichost \
@@ -66,148 +58,18 @@ docker run -d \
muxui/pichost:latest muxui/pichost:latest
``` ```
默认端口 **6892**。浏览器打开 `http://<主机IP>:6892`,按引导创建管理员即可。 浏览器打开 `http://<主机IP>:6892`,按引导完成设置即可。
**忘记密码**(需能执行 `docker exec`,相当于服务器权限): **详细说明**(环境变量、升级迁移、双域名、API、Twikoo、反向代理、本地开发等)见 **[用户文档](https://o96u.github.io/PicHost/)**。
```bash | 分支 | 说明 |
# 无参数:重置管理员(仅当系统中只有一个管理员时) | ---- | ---- |
docker exec pichost reset-password | [**main**](https://github.com/O96u/PicHost/tree/main) | 默认主线:多后端、双域名、统一 `images/` 存储 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | 仅 Cloudflare R2;在线演示 [pic.roven.cc](https://pic.roven.cc) |
# 指定用户名:可重置管理员或普通用户
docker exec pichost reset-password 用户名
```
终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。
本地:`npm run reset-password` 或 `npm run reset-password -- 用户名`
已克隆仓库时也可用 `docker compose up -d`(见仓库内 `docker-compose.yml`)。
### 本地开发
```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.3** | CI 与 Docker 多架构构建修复(当前) |
| **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复 |
| **v1.0.0** | 本地存储、多用户、API、Twikoo |
| **v1.1.0** | S3、Cloudflare R2 等对象存储后端 |
## License ## License
[MIT](LICENSE) [GPL-3.0](LICENSE)
## 友链 ## 友链
+27 -4
View File
@@ -11,14 +11,29 @@ const username = ref('')
const password = ref('') const password = ref('')
const secret = ref('') const secret = ref('')
const loading = ref(false) const loading = ref(false)
const captchaRef = ref<{ reset: () => Promise<void> } | null>(null)
const captchaPayload = ref<{ captchaId: string, captchaPosition: number } | null>(null)
const legacyMode = computed(() => authStatus.value?.legacyMode ?? false) const legacyMode = computed(() => authStatus.value?.legacyMode ?? false)
const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false) const allowRegistration = computed(() => authStatus.value?.allowRegistration ?? false)
const canSubmit = computed(() => const canSubmit = computed(() =>
legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value) Boolean(captchaPayload.value)
&& (legacyMode.value ? Boolean(secret.value) : Boolean(username.value && password.value))
) )
function onCaptchaVerified(payload: { id: string, positionPercent: number }) {
captchaPayload.value = {
captchaId: payload.id,
captchaPosition: payload.positionPercent
}
}
async function resetCaptcha() {
captchaPayload.value = null
await captchaRef.value?.reset()
}
onMounted(async () => { onMounted(async () => {
const status = await fetchStatus() const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) { if (!status.initialized && !status.legacyMode) {
@@ -27,13 +42,14 @@ onMounted(async () => {
}) })
async function submit() { async function submit() {
if (!canSubmit.value || loading.value) return if (!canSubmit.value || loading.value || !captchaPayload.value) return
loading.value = true loading.value = true
try { try {
const captcha = captchaPayload.value
const result = legacyMode.value const result = legacyMode.value
? await login({ secret: secret.value }) ? await login({ secret: secret.value }, captcha)
: await login({ username: username.value.trim(), password: password.value }) : await login({ username: username.value.trim(), password: password.value }, captcha)
if (result.ok) { if (result.ok) {
if (result.needsMigration) { if (result.needsMigration) {
@@ -43,8 +59,10 @@ async function submit() {
username.value = '' username.value = ''
password.value = '' password.value = ''
secret.value = '' secret.value = ''
await resetCaptcha()
toast.add({ title: t('auth.loginSuccess'), color: 'success' }) toast.add({ title: t('auth.loginSuccess'), color: 'success' })
} else { } else {
await resetCaptcha()
toast.add({ toast.add({
title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')), title: result.error ?? (legacyMode.value ? t('auth.loginFailedSecret') : t('auth.loginFailedCredentials')),
color: 'error' color: 'error'
@@ -152,6 +170,11 @@ async function submit() {
</div> </div>
</template> </template>
<SliderCaptcha
ref="captchaRef"
@verified="onCaptchaVerified"
/>
<UButton <UButton
type="submit" type="submit"
:label="t('auth.login')" :label="t('auth.login')"
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
const props = withDefaults(
defineProps<{
code: string
scrollable?: boolean
}>(),
{
scrollable: true
}
)
const toast = useToast()
const { t } = useI18n()
async function copyCode() {
try {
await navigator.clipboard.writeText(props.code)
toast.add({ title: t('copy.curl'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
}
}
</script>
<template>
<div class="overflow-hidden rounded-xl bg-neutral-950">
<div
class="flex items-center justify-between border-b border-neutral-800/80 bg-neutral-900 px-3.5 py-2.5"
>
<div
class="flex items-center gap-2"
aria-hidden="true"
>
<span class="size-3 rounded-full border border-black/15 bg-[#ff5f56]" />
<span class="size-3 rounded-full border border-black/15 bg-[#ffbd2e]" />
<span class="size-3 rounded-full border border-black/15 bg-[#27c93f]" />
</div>
<UButton
icon="i-lucide-copy"
:label="t('common.copy')"
size="xs"
variant="ghost"
color="neutral"
class="text-neutral-300 hover:text-white"
@click="copyCode"
/>
</div>
<pre
class="overflow-auto p-4 text-xs leading-relaxed text-neutral-100"
:class="props.scrollable ? 'max-h-80' : ''"
><code class="font-mono">{{ props.code }}</code></pre>
</div>
</template>
+18 -22
View File
@@ -16,44 +16,40 @@ const navItems = computed(() => {
{ label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' } { label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' }
] ]
if (isAdmin.value) { if (isAdmin.value) {
items.push({ label: t('nav.storage'), to: '/storage', icon: 'i-lucide-hard-drive' })
items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' }) items.push({ label: t('nav.settings'), to: '/settings', icon: 'i-lucide-settings' })
} }
return items return items
}) })
const accountMenuItems = computed<DropdownMenuItem[]>(() => { const accountMenuItems = computed<DropdownMenuItem[]>(() => [
const items: DropdownMenuItem[] = [ {
{ label: t('nav.logs'),
label: t('nav.changePassword'), icon: 'i-lucide-scroll-text',
icon: 'i-lucide-key-round', to: '/logs'
to: '/account/password' },
} {
] label: t('nav.changePassword'),
icon: 'i-lucide-key-round',
if (isAdmin.value) { to: '/account/password'
items.push({ },
label: t('nav.systemSettings'), {
icon: 'i-lucide-settings',
to: '/settings'
})
}
items.push({
label: t('nav.logout'), label: t('nav.logout'),
icon: 'i-lucide-log-out', icon: 'i-lucide-log-out',
onSelect() { onSelect() {
void handleLogout() void handleLogout()
} }
}) }
])
return items
})
const desktopUserMenuItems = computed<DropdownMenuItem[][]>(() => [ const desktopUserMenuItems = computed<DropdownMenuItem[][]>(() => [
accountMenuItems.value accountMenuItems.value
]) ])
function isNavActive(to: string) { function isNavActive(to: string) {
if (to === '/settings') {
return route.path === '/settings'
}
return route.path.startsWith(to) return route.path.startsWith(to)
} }
+73 -7
View File
@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ImageItem } from '~/types/image' import type { ImageItem } from '~/types/image'
import type { CopyFormat } from '~/composables/useUploadPreferences'
type CopyFormat = 'url' | 'markdown' | 'html'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
image: ImageItem image: ImageItem
@@ -9,10 +8,12 @@ const props = withDefaults(defineProps<{
deleting?: boolean deleting?: boolean
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean
compact?: boolean compact?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true, showKey: true,
showStorage: false,
compact: false compact: false
}) })
@@ -23,8 +24,52 @@ const emit = defineEmits<{
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const { copyFormat } = useUploadPreferences()
const imageError = ref(false) const imageError = ref(false)
const copyFormat = ref<CopyFormat>('url') const retryCount = ref(0)
function selectCopyFormat(value: CopyFormat) {
copyFormat.value = value
}
const MAX_PREVIEW_RETRIES = 3
const PREVIEW_RETRY_DELAYS_MS = [1000, 2000, 4000]
const previewSrc = computed(() => {
if (retryCount.value === 0) return props.image.url
const separator = props.image.url.includes('?') ? '&' : '?'
return `${props.image.url}${separator}retry=${retryCount.value}`
})
watch(() => props.image.key, () => {
imageError.value = false
retryCount.value = 0
})
let retryTimer: ReturnType<typeof setTimeout> | undefined
function clearRetryTimer() {
if (retryTimer !== undefined) {
clearTimeout(retryTimer)
retryTimer = undefined
}
}
function onPreviewError() {
if (retryCount.value >= MAX_PREVIEW_RETRIES) {
imageError.value = true
return
}
const delay = PREVIEW_RETRY_DELAYS_MS[retryCount.value] ?? PREVIEW_RETRY_DELAYS_MS.at(-1)!
clearRetryTimer()
retryTimer = setTimeout(() => {
retryCount.value += 1
retryTimer = undefined
}, delay)
}
onUnmounted(clearRetryTimer)
const copyFormatItems = computed(() => [ const copyFormatItems = computed(() => [
{ label: t('copy.url'), value: 'url' as const }, { label: t('copy.url'), value: 'url' as const },
@@ -62,6 +107,10 @@ const copySuccessTitle = computed(() => {
} }
}) })
const storageIcon = computed(() =>
props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
function toggleSelected(value: boolean | 'indeterminate') { function toggleSelected(value: boolean | 'indeterminate') {
emit('update:selected', value === true) emit('update:selected', value === true)
} }
@@ -72,11 +121,12 @@ function toggleSelected(value: boolean | 'indeterminate') {
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted"> <div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
<img <img
v-if="!imageError" v-if="!imageError"
:src="image.url" :key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName" :alt="image.originalName"
loading="lazy" loading="lazy"
class="absolute inset-0 size-full object-cover object-center" class="absolute inset-0 size-full object-cover object-center"
@error="imageError = true" @error="onPreviewError"
> >
<div <div
v-else v-else
@@ -106,7 +156,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
</div> </div>
<div <div
v-if="image.owner" v-if="image.owner"
class="absolute bottom-2 left-2 max-w-[calc(100%-1rem)]" class="absolute bottom-2 left-2 max-w-[calc(50%-0.5rem)]"
> >
<span <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" 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"
@@ -119,6 +169,22 @@ function toggleSelected(value: boolean | 'indeterminate') {
<span class="truncate">{{ image.owner.username }}</span> <span class="truncate">{{ image.owner.username }}</span>
</span> </span>
</div> </div>
<div
v-if="showStorage && image.storage"
class="absolute bottom-2 max-w-[calc(50%-0.5rem)]"
:class="image.owner ? 'right-2' : 'left-2'"
>
<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="t('image.storageBackend', { name: image.storage.name })"
>
<UIcon
:name="storageIcon"
class="size-3 shrink-0"
/>
<span class="truncate">{{ image.storage.name }}</span>
</span>
</div>
</div> </div>
<div <div
@@ -168,7 +234,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
:variant="copyFormat === item.value ? 'solid' : 'ghost'" :variant="copyFormat === item.value ? 'solid' : 'ghost'"
:color="copyFormat === item.value ? 'primary' : 'neutral'" :color="copyFormat === item.value ? 'primary' : 'neutral'"
:label="item.label" :label="item.label"
@click="() => { copyFormat = item.value }" @click="selectCopyFormat(item.value)"
/> />
</div> </div>
+3
View File
@@ -7,12 +7,14 @@ const props = withDefaults(defineProps<{
deletingKeys: Set<string> deletingKeys: Set<string>
selectable?: boolean selectable?: boolean
showKey?: boolean showKey?: boolean
showStorage?: boolean
emptyText?: string emptyText?: string
dense?: boolean dense?: boolean
compact?: boolean compact?: boolean
}>(), { }>(), {
selectable: true, selectable: true,
showKey: true, showKey: true,
showStorage: false,
dense: false, dense: false,
compact: false compact: false
}) })
@@ -48,6 +50,7 @@ function updateSelection(key: string, selected: boolean) {
:deleting="deletingKeys.has(image.key)" :deleting="deletingKeys.has(image.key)"
:selectable="selectable" :selectable="selectable"
:show-key="showKey" :show-key="showKey"
:show-storage="showStorage"
:compact="compact" :compact="compact"
@update:selected="updateSelection(image.key, $event)" @update:selected="updateSelection(image.key, $event)"
@delete="emit('delete', image.key)" @delete="emit('delete', image.key)"
+19 -20
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const open = defineModel<boolean>('open', { default: false }) const open = defineModel<boolean>('open', { default: false })
const { logout, isAdmin } = useAuth() const { logout } = useAuth()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const colorMode = useColorMode() const colorMode = useColorMode()
const { const {
@@ -136,6 +136,24 @@ function toggleOpen() {
{{ t('nav.account') }} {{ t('nav.account') }}
</h3> </h3>
<div class="space-y-2"> <div class="space-y-2">
<NuxtLink
to="/logs"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
@click="open = false"
>
<span class="flex items-center gap-2.5">
<UIcon
name="i-lucide-scroll-text"
class="size-4 opacity-70"
/>
{{ t('nav.logs') }}
</span>
<UIcon
name="i-lucide-chevron-right"
class="size-4 text-muted"
/>
</NuxtLink>
<NuxtLink <NuxtLink
to="/account/password" to="/account/password"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated" class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
@@ -154,25 +172,6 @@ function toggleOpen() {
/> />
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/settings"
class="flex items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-highlighted transition-colors hover:bg-elevated"
@click="open = false"
>
<span class="flex items-center gap-2.5">
<UIcon
name="i-lucide-settings"
class="size-4 opacity-70"
/>
{{ t('nav.systemSettings') }}
</span>
<UIcon
name="i-lucide-chevron-right"
class="size-4 text-muted"
/>
</NuxtLink>
<button <button
type="button" type="button"
class="flex w-full items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-error transition-colors hover:bg-error/5" class="flex w-full items-center justify-between rounded-lg border border-default px-3.5 py-2.5 text-sm text-error transition-colors hover:bg-error/5"
+44 -2
View File
@@ -14,13 +14,30 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const unitLabel = computed(() => props.unit ?? t('common.itemsUnit')) const unitLabel = computed(() => props.unit ?? t('common.itemsUnit'))
const jumpInput = ref('')
function goTo(target: number) { function goTo(target: number) {
if (target < 1 || target > props.totalPages || target === props.page || props.loading) { if (target < 1 || target > props.totalPages || props.loading) {
return
}
if (target === props.page) {
jumpInput.value = ''
return return
} }
emit('update:page', target) emit('update:page', target)
} }
function submitJump() {
const raw = String(jumpInput.value ?? '').trim()
if (!raw) return
const parsed = Number.parseInt(raw, 10)
if (!Number.isFinite(parsed)) return
goTo(parsed)
}
watch(() => props.page, () => {
jumpInput.value = ''
})
</script> </script>
<template> <template>
@@ -43,7 +60,7 @@ function goTo(target: number) {
<div <div
v-if="totalPages > 1" v-if="totalPages > 1"
class="flex items-center gap-1" class="flex flex-wrap items-center justify-center gap-1"
> >
<UButton <UButton
icon="i-lucide-chevrons-left" icon="i-lucide-chevrons-left"
@@ -77,6 +94,31 @@ function goTo(target: number) {
:disabled="page >= totalPages || loading" :disabled="page >= totalPages || loading"
@click="goTo(totalPages)" @click="goTo(totalPages)"
/> />
<form
class="ml-1 flex items-center gap-1"
@submit.prevent="submitJump"
>
<UInput
v-model="jumpInput"
type="number"
:min="1"
:max="totalPages"
size="sm"
class="w-16"
:placeholder="t('pagination.pagePlaceholder')"
:disabled="loading"
:aria-label="t('pagination.pagePlaceholder')"
@keyup.enter="submitJump"
/>
<UButton
type="button"
size="sm"
variant="outline"
:label="t('pagination.goTo')"
:disabled="loading || !String(jumpInput ?? '').trim()"
@click="submitJump"
/>
</form>
</div> </div>
</div> </div>
</template> </template>
+261
View File
@@ -0,0 +1,261 @@
<script setup lang="ts">
const emit = defineEmits<{
verified: [payload: { id: string, positionPercent: number }]
}>()
const { t } = useI18n()
const THUMB_SIZE = 40
const TRACK_INSET = 4
const trackRef = ref<HTMLElement | null>(null)
const challengeId = ref('')
const trackSvg = ref('')
const sliderPercent = ref(0)
const verified = ref(false)
const loading = ref(false)
const dragging = ref(false)
const shake = ref(false)
const confirming = ref(false)
const trackWidth = ref(0)
function updateTrackWidth() {
trackWidth.value = trackRef.value?.clientWidth ?? 0
}
function percentToPx(percent: number) {
const max = Math.max(trackWidth.value - THUMB_SIZE - TRACK_INSET * 2, 0)
return TRACK_INSET + (percent / 100) * max
}
const thumbLeft = computed(() => percentToPx(sliderPercent.value))
const isActive = computed(() => dragging.value || sliderPercent.value > 0.5)
const fillWidth = computed(() => `${Math.max(thumbLeft.value + THUMB_SIZE - TRACK_INSET, 0)}px`)
const trackSvgUrl = computed(() =>
trackSvg.value
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(trackSvg.value)}`
: ''
)
const hintOpacity = computed(() => {
if (verified.value) return 0
if (!isActive.value) return 1
return Math.max(0, 1 - sliderPercent.value / 40)
})
const successLabelLeft = computed(() => `${thumbLeft.value + THUMB_SIZE + 8}px`)
async function loadChallenge() {
loading.value = true
verified.value = false
dragging.value = false
shake.value = false
sliderPercent.value = 0
trackSvg.value = ''
try {
const data = await $fetch<{ id: string, svg: string }>('/api/auth/captcha')
challengeId.value = data.id
trackSvg.value = data.svg
} finally {
loading.value = false
await nextTick()
updateTrackWidth()
}
}
function setSliderFromClientX(clientX: number) {
if (!trackRef.value || verified.value || loading.value || confirming.value) return
const rect = trackRef.value.getBoundingClientRect()
const max = Math.max(rect.width - THUMB_SIZE - TRACK_INSET * 2, 1)
const x = Math.min(
Math.max(clientX - rect.left - THUMB_SIZE / 2 - TRACK_INSET, 0),
max
)
sliderPercent.value = (x / max) * 100
}
async function tryConfirm() {
if (verified.value || !challengeId.value || confirming.value) return
confirming.value = true
try {
const position = Math.round(sliderPercent.value)
await $fetch('/api/auth/captcha/confirm', {
method: 'POST',
body: {
id: challengeId.value,
captchaPosition: position
}
})
verified.value = true
emit('verified', {
id: challengeId.value,
positionPercent: position
})
} catch {
shake.value = true
sliderPercent.value = 0
window.setTimeout(() => {
shake.value = false
}, 450)
} finally {
confirming.value = false
}
}
function onThumbPointerDown(event: PointerEvent) {
if (verified.value || loading.value || confirming.value) return
dragging.value = true
updateTrackWidth()
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onThumbPointerMove(event: PointerEvent) {
if (!dragging.value) return
setSliderFromClientX(event.clientX)
}
function onThumbPointerUp() {
if (!dragging.value) return
dragging.value = false
if (!verified.value) {
void tryConfirm()
}
}
onMounted(() => {
void loadChallenge()
})
let resizeObserver: ResizeObserver | undefined
watch(trackRef, (el, _, onCleanup) => {
resizeObserver?.disconnect()
resizeObserver = undefined
if (!el) return
updateTrackWidth()
if (typeof ResizeObserver === 'undefined') return
resizeObserver = new ResizeObserver(updateTrackWidth)
resizeObserver.observe(el)
onCleanup(() => {
resizeObserver?.disconnect()
resizeObserver = undefined
})
})
defineExpose({ reset: loadChallenge })
</script>
<template>
<div
ref="trackRef"
class="captcha-track relative h-12 select-none overflow-hidden rounded-lg border transition-[background-color,border-color] duration-300"
:class="[
verified
? 'border-primary/35 bg-primary/10'
: 'border-default bg-muted/20',
loading ? 'opacity-70' : '',
shake ? 'captcha-shake' : ''
]"
>
<div
v-if="loading"
class="absolute inset-0 flex items-center justify-center"
>
<UIcon
name="i-lucide-loader-2"
class="size-5 animate-spin text-muted"
/>
</div>
<template v-else>
<img
v-if="trackSvgUrl"
:src="trackSvgUrl"
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full object-fill transition-opacity duration-300"
:class="verified ? 'opacity-0' : 'opacity-100'"
draggable="false"
>
<div
v-if="isActive && !verified"
class="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-primary/20 transition-[width] duration-75"
:style="{ width: fillWidth }"
/>
<p
v-if="!verified"
class="pointer-events-none absolute inset-0 flex items-center justify-center pl-12 text-sm text-muted transition-opacity duration-150"
:style="{ opacity: hintOpacity }"
>
{{ t('auth.captchaHint') }}
</p>
<p
v-if="verified"
class="pointer-events-none absolute inset-y-0 flex items-center pr-3 text-sm font-medium text-primary"
:style="{ left: successLabelLeft }"
>
{{ t('auth.captchaSuccess') }}
</p>
<div
class="absolute top-1/2 -translate-y-1/2 touch-none rounded-md border transition-[left,background-color,border-color,color,box-shadow,transform] duration-150"
:class="[
verified
? 'z-20 border-primary bg-primary text-white shadow-sm shadow-primary/25'
: isActive
? 'z-10 border-primary/45 bg-elevated text-primary shadow-md'
: 'z-10 border-default/90 bg-elevated text-muted shadow-sm',
dragging ? 'scale-[1.02] cursor-grabbing' : verified ? 'cursor-default' : 'cursor-grab'
]"
:style="{
left: `${thumbLeft}px`,
width: `${THUMB_SIZE}px`,
height: `${THUMB_SIZE}px`
}"
@pointerdown="onThumbPointerDown"
@pointermove="onThumbPointerMove"
@pointerup="onThumbPointerUp"
@pointercancel="onThumbPointerUp"
>
<div class="flex size-full items-center justify-center">
<UIcon
v-if="confirming"
name="i-lucide-loader-2"
class="size-4 animate-spin"
:class="verified ? 'text-white' : isActive ? 'text-primary' : 'text-muted'"
/>
<UIcon
v-else
:name="verified ? 'i-lucide-check' : 'i-lucide-chevrons-right'"
class="size-4"
/>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.captcha-shake {
animation: captcha-shake 0.4s ease;
}
@keyframes captcha-shake {
0%,
100% {
transform: translateX(0);
}
25%,
75% {
transform: translateX(-4px);
}
50% {
transform: translateX(4px);
}
}
</style>
-111
View File
@@ -1,111 +0,0 @@
<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,215 @@
<script setup lang="ts">
import type { StorageBackendItem } from '~/types/storage'
type CardAction = {
key: string
icon: string
label: string
shortLabel: string
danger?: boolean
disabled?: boolean
onClick: () => void
}
const props = defineProps<{
backend: StorageBackendItem
envOverride?: boolean
busy?: boolean
}>()
const emit = defineEmits<{
setDefault: []
edit: []
test: []
toggleEnabled: []
delete: []
}>()
const { t } = useI18n()
const typeIcon = computed(() =>
props.backend.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
const typeLabel = computed(() =>
props.backend.type === 'local' ? t('storage.backendLocal') : t('storage.backendCloud')
)
const servingLabel = computed(() =>
props.backend.servingMode === 'public'
? t('storage.servingPublic')
: t('storage.servingProxy')
)
const bucketLine = computed(() => {
if (props.backend.type !== 's3') return null
return props.backend.config.bucket || t('storage.notConfigured')
})
const actionsDisabled = computed(() => props.envOverride || props.busy)
const actions = computed<CardAction[]>(() => {
const isLocal = props.backend.type === 'local'
const items: CardAction[] = []
if (!props.backend.isDefault && props.backend.enabled) {
items.push({
key: 'default',
icon: 'i-lucide-star',
label: t('storage.setDefault'),
shortLabel: isLocal ? t('storage.setDefault') : t('storage.actionSetDefault'),
onClick: () => emit('setDefault')
})
}
if (props.backend.type === 's3') {
items.push({
key: 'edit',
icon: 'i-lucide-pencil',
label: t('common.edit'),
shortLabel: t('common.edit'),
onClick: () => emit('edit')
})
}
items.push({
key: 'test',
icon: 'i-lucide-plug-zap',
label: t('storage.testConnection'),
shortLabel: isLocal ? t('storage.testConnection') : t('storage.actionTest'),
onClick: () => emit('test')
})
if (props.backend.type !== 'local') {
items.push({
key: 'toggle',
icon: props.backend.enabled ? 'i-lucide-pause' : 'i-lucide-play',
label: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
shortLabel: props.backend.enabled ? t('storage.disable') : t('storage.enable'),
disabled: props.backend.isDefault && props.backend.enabled,
onClick: () => emit('toggleEnabled')
})
}
if (props.backend.type !== 'local' && !props.backend.isDefault) {
items.push({
key: 'delete',
icon: 'i-lucide-trash-2',
label: t('common.delete'),
shortLabel: t('common.delete'),
danger: true,
onClick: () => emit('delete')
})
}
return items
})
const actionGridClass = computed(() => {
switch (actions.value.length) {
case 1: return 'grid-cols-1'
case 2: return 'grid-cols-2'
case 3: return 'grid-cols-3'
case 4: return 'grid-cols-4'
default: return 'grid-cols-5'
}
})
</script>
<template>
<article
class="flex h-full flex-col rounded-2xl border border-default bg-elevated p-4 shadow-sm"
:class="{
'ring-2 ring-primary/30': backend.isDefault,
'opacity-75': !backend.enabled
}"
>
<div class="mb-3 flex items-start justify-between gap-2">
<div class="flex min-w-0 items-start gap-2.5">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
>
<UIcon
:name="typeIcon"
class="size-4.5"
/>
</div>
<div class="min-w-0">
<h3 class="truncate text-sm font-semibold">
{{ backend.name }}
</h3>
<p class="mt-0.5 text-xs text-muted">
{{ typeLabel }}
<template v-if="bucketLine">
· {{ bucketLine }}
</template>
</p>
</div>
</div>
<div class="flex shrink-0 flex-wrap justify-end gap-1">
<UBadge
v-if="backend.enabled"
color="success"
variant="subtle"
size="xs"
>
{{ t('storage.badgeEnabled') }}
</UBadge>
<UBadge
v-if="backend.isDefault"
color="primary"
variant="subtle"
size="xs"
>
{{ t('storage.badgeDefault') }}
</UBadge>
<UBadge
v-if="!backend.enabled"
color="warning"
variant="subtle"
size="xs"
>
{{ t('storage.badgeDisabled') }}
</UBadge>
</div>
</div>
<StorageUsageBar
:capacity="backend.capacity"
:index-bytes="backend.usage.bytes"
/>
<p class="mt-3 text-xs text-muted">
{{ t('storage.servingMode') }}: {{ servingLabel }}
</p>
<div class="mt-4 border-t border-default pt-4">
<div
class="grid gap-1.5"
:class="actionGridClass"
>
<button
v-for="action in actions"
:key="action.key"
type="button"
class="flex flex-col items-center justify-center gap-1 rounded-lg border px-1 py-2 transition disabled:cursor-not-allowed disabled:opacity-40"
:class="action.danger
? 'border-error/30 text-error hover:bg-error/5'
: 'border-default text-muted hover:bg-muted/40 hover:text-highlighted'"
:title="action.label"
:aria-label="action.label"
:disabled="actionsDisabled || action.disabled"
@click="action.onClick"
>
<UIcon
:name="action.icon"
class="size-4 shrink-0"
/>
<span class="w-full truncate text-center text-[11px] leading-none">
{{ action.shortLabel }}
</span>
</button>
</div>
</div>
</article>
</template>
@@ -0,0 +1,345 @@
<script setup lang="ts">
import type { QuotaUnit } from '~/composables/useFileSize'
import type { StorageBackendItem } from '~/types/storage'
type StorageProvider = 'r2' | 'cos' | 'oss' | 'aws' | 'custom'
export interface StorageFormPayload {
name: string
config: {
endpoint: string
region: string
bucket: string
prefix?: string
forcePathStyle: boolean
}
secrets: {
accessKeyId: string
secretAccessKey: string
}
servingMode: 'proxy' | 'public'
publicUrl: string
quotaValue: string
quotaUnit: QuotaUnit
}
const props = defineProps<{
backend?: StorageBackendItem | null
createMode?: boolean
saving?: boolean
}>()
const emit = defineEmits<{
submit: [payload: StorageFormPayload]
cancel: []
}>()
const { t } = useI18n()
const { bytesToQuotaInput, QUOTA_UNITS } = useFileSize()
const name = ref('')
const provider = ref<StorageProvider>('r2')
const endpoint = ref('')
const region = ref('auto')
const bucket = ref('')
const prefix = ref('')
const forcePathStyle = ref(false)
const accessKeyId = ref('')
const secretAccessKey = ref('')
const servingMode = ref<'proxy' | 'public'>('proxy')
const publicUrl = ref('')
const quotaValue = ref('')
const quotaUnit = ref<QuotaUnit>('GB')
const quotaUnitOptions = computed(() =>
QUOTA_UNITS.map(unit => ({ label: unit, value: unit }))
)
const providerOptions = computed(() => [
{ label: t('storage.providerR2'), value: 'r2' as const },
{ label: t('storage.providerCos'), value: 'cos' as const },
{ label: t('storage.providerOss'), value: 'oss' as const },
{ label: t('storage.providerAws'), value: 'aws' as const },
{ label: t('storage.providerCustom'), value: 'custom' as const }
])
const servingModeOptions = computed(() => [
{ label: t('storage.servingProxy'), value: 'proxy' },
{ label: t('storage.servingPublic'), value: 'public' }
])
function providerLabel(value: StorageProvider): string {
return providerOptions.value.find(option => option.value === value)?.label
?? t('storage.backendCloud')
}
function applyProviderTemplate(value: StorageProvider) {
if (!props.createMode && props.backend) return
if (props.createMode) {
name.value = providerLabel(value)
}
switch (value) {
case 'r2':
endpoint.value = 'https://{account_id}.r2.cloudflarestorage.com'
region.value = 'auto'
forcePathStyle.value = false
break
case 'cos':
endpoint.value = 'https://cos.{region}.myqcloud.com'
region.value = 'ap-guangzhou'
forcePathStyle.value = true
break
case 'oss':
endpoint.value = 'https://oss-{region}.aliyuncs.com'
region.value = 'cn-hangzhou'
forcePathStyle.value = false
break
case 'aws':
endpoint.value = 'https://s3.{region}.amazonaws.com'
region.value = 'us-east-1'
forcePathStyle.value = false
break
default:
break
}
}
function detectProvider(config: StorageBackendItem['config']): StorageProvider {
const ep = config.endpoint ?? ''
if (ep.includes('r2.cloudflarestorage.com')) return 'r2'
if (ep.includes('myqcloud.com')) return 'cos'
if (ep.includes('aliyuncs.com')) return 'oss'
if (ep.includes('amazonaws.com')) return 'aws'
return 'custom'
}
function loadBackend(backend: StorageBackendItem | null | undefined) {
if (!backend || backend.type !== 's3') {
if (props.createMode) {
name.value = ''
provider.value = 'r2'
applyProviderTemplate('r2')
bucket.value = ''
prefix.value = ''
accessKeyId.value = ''
secretAccessKey.value = ''
servingMode.value = 'proxy'
publicUrl.value = ''
quotaValue.value = ''
quotaUnit.value = 'GB'
}
return
}
name.value = backend.name
provider.value = detectProvider(backend.config)
endpoint.value = backend.config.endpoint ?? ''
region.value = backend.config.region ?? 'auto'
bucket.value = backend.config.bucket ?? ''
prefix.value = backend.config.prefix ?? ''
forcePathStyle.value = backend.config.forcePathStyle ?? false
servingMode.value = backend.servingMode
publicUrl.value = backend.publicUrl
const quota = bytesToQuotaInput(backend.quotaBytes)
quotaValue.value = quota.value
quotaUnit.value = quota.unit
accessKeyId.value = ''
secretAccessKey.value = ''
}
watch(() => props.backend, loadBackend, { immediate: true })
watch(provider, (value) => {
if (props.createMode) {
applyProviderTemplate(value)
}
})
function handleSubmit() {
emit('submit', {
name: name.value.trim() || providerLabel(provider.value),
config: {
endpoint: endpoint.value.trim(),
region: region.value.trim() || 'auto',
bucket: bucket.value.trim(),
prefix: prefix.value.trim() || undefined,
forcePathStyle: forcePathStyle.value
},
secrets: {
accessKeyId: accessKeyId.value.trim(),
secretAccessKey: secretAccessKey.value.trim()
},
servingMode: servingMode.value,
publicUrl: publicUrl.value.trim(),
quotaValue: String(quotaValue.value ?? '').trim(),
quotaUnit: quotaUnit.value
})
}
</script>
<template>
<form
class="space-y-4"
@submit.prevent="handleSubmit"
>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.provider') }}</label>
<USelect
v-model="provider"
:items="providerOptions"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.backendName') }}</label>
<UInput
v-model="name"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.endpoint') }}</label>
<UInput
v-model="endpoint"
class="w-full font-mono text-sm"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-sm">{{ t('storage.region') }}</label>
<UInput
v-model="region"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.bucket') }}
<span class="text-error">*</span>
</label>
<UInput
v-model="bucket"
class="w-full font-mono text-sm"
required
/>
</div>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.prefix') }}</label>
<UInput
v-model="prefix"
:placeholder="t('storage.prefixPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
<label class="flex cursor-pointer items-center gap-2">
<UCheckbox v-model="forcePathStyle" />
<span class="text-sm">{{ t('storage.forcePathStyle') }}</span>
</label>
<div class="grid gap-3 sm:grid-cols-2">
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.accessKey') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="accessKeyId"
:placeholder="backend?.secretsMasked.accessKeyId || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.secretKey') }}
<span
v-if="createMode"
class="text-error"
>*</span>
</label>
<UInput
v-model="secretAccessKey"
type="password"
:placeholder="backend?.secretsMasked.secretAccessKey || ''"
class="w-full font-mono text-sm"
:required="createMode"
/>
</div>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('storage.servingMode') }}</label>
<URadioGroup
v-model="servingMode"
:items="servingModeOptions"
/>
<p class="text-xs text-muted">
{{ t('storage.servingHint') }}
</p>
</div>
<div
v-if="servingMode === 'public'"
class="space-y-2"
>
<label class="text-sm">{{ t('storage.publicUrl') }}</label>
<UInput
v-model="publicUrl"
:placeholder="t('storage.publicUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm">
{{ t('storage.quotaTotal') }}
<span class="text-error">*</span>
</label>
<div class="flex gap-2">
<UInput
:model-value="String(quotaValue)"
type="number"
min="0"
step="any"
required
:placeholder="t('storage.quotaPlaceholder')"
class="min-w-0 flex-1"
@update:model-value="quotaValue = String($event ?? '')"
/>
<USelect
v-model="quotaUnit"
:items="quotaUnitOptions"
class="w-24 shrink-0"
/>
</div>
<p class="text-xs text-muted">
{{ t('storage.quotaHint') }}
</p>
</div>
<div class="flex justify-end gap-2 pt-2">
<UButton
type="button"
:label="t('common.cancel')"
variant="outline"
color="neutral"
@click="emit('cancel')"
/>
<UButton
type="submit"
:label="t('common.save')"
icon="i-lucide-save"
:loading="saving"
/>
</div>
</form>
</template>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { StorageCapacity } from '~/types/storage'
const props = defineProps<{
capacity: StorageCapacity
indexBytes: number
}>()
const { formatFileSize } = useFileSize()
const displayUsedBytes = computed(() =>
props.capacity.source === 'disk' ? props.capacity.usedBytes : props.indexBytes
)
const hasBar = computed(() =>
props.capacity.totalBytes != null && props.capacity.totalBytes > 0
)
const displayPercent = computed(() => props.capacity.percent ?? 0)
const overQuota = computed(() =>
props.capacity.source === 'quota' && displayPercent.value > 100
)
const barWidth = computed(() => {
if (!hasBar.value) return 0
const percent = Math.min(displayPercent.value, 100)
return Math.max(percent, displayUsedBytes.value > 0 ? 4 : 0)
})
function formatOptional(bytes: number | null): string {
if (bytes == null) return '—'
return formatFileSize(bytes)
}
</script>
<template>
<div class="space-y-3">
<div class="grid grid-cols-3 gap-2 text-center text-xs">
<div class="rounded-lg bg-muted/30 px-2 py-2">
<p class="text-muted">
{{ $t('storage.statTotal') }}
</p>
<p class="mt-1 font-medium tabular-nums text-highlighted">
{{ formatOptional(capacity.totalBytes) }}
</p>
</div>
<div class="rounded-lg bg-muted/30 px-2 py-2">
<p class="text-muted">
{{ $t('storage.statUsed') }}
</p>
<p class="mt-1 font-medium tabular-nums text-highlighted">
{{ formatFileSize(displayUsedBytes) }}
</p>
</div>
<div class="rounded-lg bg-muted/30 px-2 py-2">
<p class="text-muted">
{{ $t('storage.statFree') }}
</p>
<p
class="mt-1 font-medium tabular-nums"
:class="overQuota ? 'text-error' : 'text-highlighted'"
>
{{ formatOptional(capacity.freeBytes) }}
</p>
</div>
</div>
<div
v-if="hasBar"
class="space-y-1"
>
<div class="flex items-center justify-between text-xs">
<span class="text-muted">{{ $t('storage.usageRate') }}</span>
<span
class="tabular-nums font-medium"
:class="overQuota ? 'text-error' : ''"
>
{{ displayPercent }}%
</span>
</div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50">
<div
class="h-full rounded-full transition-all"
:class="overQuota ? 'bg-error/80' : 'bg-primary/70'"
:style="{ width: `${barWidth}%` }"
/>
</div>
</div>
</div>
</template>
+34 -6
View File
@@ -66,13 +66,20 @@ export function getFetchErrorMessage(error: unknown, fallback: string): string {
export type AuthActionResult = { ok: true } | { ok: false, error: string } export type AuthActionResult = { ok: true } | { ok: false, error: string }
export interface LoginCaptchaPayload {
captchaId: string
captchaPosition: number
}
export function useAuth() { export function useAuth() {
const { t } = useI18n() const { t } = useI18n()
const status = useState<AuthStatus>('auth-status', () => 'checking') const status = useState<AuthStatus>('auth-status', () => 'checking')
const user = useState<AuthUser | null>('auth-user', () => null) const user = useState<AuthUser | null>('auth-user', () => null)
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null) const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
/** 是否已完成至少一次会话校验(用于避免路由切换时反复全屏 loading) */
const hasValidatedOnce = useState('auth-validated-once', () => false)
const isChecking = computed(() => status.value === 'checking') const isChecking = computed(() => status.value === 'checking' && !hasValidatedOnce.value)
const isAuthenticated = computed(() => status.value === 'authenticated') const isAuthenticated = computed(() => status.value === 'authenticated')
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
@@ -84,10 +91,14 @@ export function useAuth() {
return data return data
} }
async function checkSession() { async function checkSession(options?: { force?: boolean }) {
status.value = 'checking' const silent = !options?.force && hasValidatedOnce.value
if (!silent) {
status.value = 'checking'
}
try { try {
const data = await fetchStatus() const data = await fetchStatus()
hasValidatedOnce.value = true
if (data.user) { if (data.user) {
user.value = data.user user.value = data.user
status.value = 'authenticated' status.value = 'authenticated'
@@ -97,6 +108,7 @@ export function useAuth() {
status.value = 'unauthenticated' status.value = 'unauthenticated'
return false return false
} catch { } catch {
hasValidatedOnce.value = true
user.value = null user.value = null
status.value = 'unauthenticated' status.value = 'unauthenticated'
return false return false
@@ -110,7 +122,8 @@ export function useAuth() {
async function login( async function login(
credentials: { username: string, password: string } credentials: { username: string, password: string }
| { secret: string } | { secret: string },
captcha: LoginCaptchaPayload
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> { ): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
try { try {
const result = await $fetch<{ const result = await $fetch<{
@@ -120,7 +133,11 @@ export function useAuth() {
}>('/api/auth/login', { }>('/api/auth/login', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: credentials body: {
...credentials,
captchaId: captcha.captchaId,
captchaPosition: captcha.captchaPosition
}
}) })
if (result.needsMigration) { if (result.needsMigration) {
return { ok: true, needsMigration: true } return { ok: true, needsMigration: true }
@@ -129,6 +146,7 @@ export function useAuth() {
user.value = result.user user.value = result.user
} }
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -159,6 +177,7 @@ export function useAuth() {
) )
user.value = result.user user.value = result.user
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -173,6 +192,9 @@ export function useAuth() {
username: string username: string
password: string password: string
allowRegistration: boolean allowRegistration: boolean
domainSeparation?: boolean
siteBaseUrl?: string
imageBaseUrl?: string
}): Promise<AuthActionResult> { }): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password) const validationError = validateAuthInput(input.username, input.password)
if (validationError) { if (validationError) {
@@ -188,12 +210,16 @@ export function useAuth() {
body: { body: {
username: input.username.trim(), username: input.username.trim(),
password: input.password, password: input.password,
allowRegistration: input.allowRegistration allowRegistration: input.allowRegistration,
domainSeparation: input.domainSeparation,
siteBaseUrl: input.siteBaseUrl,
imageBaseUrl: input.imageBaseUrl
} }
} }
) )
user.value = result.user user.value = result.user
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -229,6 +255,7 @@ export function useAuth() {
) )
user.value = result.user user.value = result.user
status.value = 'authenticated' status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus() await fetchStatus()
return { ok: true } return { ok: true }
} catch (error: unknown) { } catch (error: unknown) {
@@ -271,6 +298,7 @@ export function useAuth() {
status.value = 'unauthenticated' status.value = 'unauthenticated'
user.value = null user.value = null
authStatus.value = null authStatus.value = null
hasValidatedOnce.value = true
} }
} }
+52 -7
View File
@@ -1,10 +1,55 @@
export function useFileSize() { export const QUOTA_UNITS = ['KB', 'MB', 'GB', 'TB'] as const
function formatFileSize(bytes: number): string { export type QuotaUnit = typeof QUOTA_UNITS[number]
if (!Number.isFinite(bytes) || bytes < 0) return '-'
if (bytes < 1024) return `${bytes} B` export function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (!Number.isFinite(bytes) || bytes < 0) return '-'
return `${(bytes / (1024 * 1024)).toFixed(2)} MB` if (bytes === 0) return '0 B'
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB'] as const
let value = bytes / 1024
let unitIndex = 0
while (unitIndex < units.length - 1 && value >= 1024) {
value /= 1024
unitIndex++
} }
return { formatFileSize } const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2
return `${value.toFixed(decimals)} ${units[unitIndex]}`
}
export function bytesToQuotaInput(bytes: number | null): { value: string, unit: QuotaUnit } {
if (!bytes || bytes <= 0) {
return { value: '', unit: 'GB' }
}
let value = bytes / 1024
let unitIndex = 0
while (unitIndex < QUOTA_UNITS.length - 1 && value >= 1024) {
value /= 1024
unitIndex++
}
const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2
const rounded = Number(value.toFixed(decimals))
const unit: QuotaUnit = QUOTA_UNITS[unitIndex] ?? 'GB'
return { value: String(rounded), unit }
}
export function quotaToBytes(value: string | number, unit: QuotaUnit): number | null {
const trimmed = String(value ?? '').trim()
if (!trimmed) return null
const amount = Number(trimmed)
if (!Number.isFinite(amount) || amount <= 0) return null
const unitIndex = QUOTA_UNITS.indexOf(unit)
if (unitIndex < 0) return null
return Math.round(amount * 1024 ** (unitIndex + 1))
}
export function useFileSize() {
return { formatFileSize, bytesToQuotaInput, quotaToBytes, QUOTA_UNITS }
} }
+79 -37
View File
@@ -12,22 +12,43 @@ export function useImageList() {
const totalPages = ref(1) const totalPages = ref(1)
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const folderTotal = ref<number | null>(null) const totalCount = ref<number | null>(null)
const loadingTotal = ref(false) const loadingTotal = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
const activeSearch = ref('') const activeSearch = ref('')
const activeFolder = ref('all') const activeStorageBackend = ref('all')
const storageBackendOptions = ref<Array<{ id: string, name: string }>>([])
function folderQueryParams(): Record<string, string> { function storageQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim() const backendId = activeStorageBackend.value.trim()
if (!folder || folder === 'all') return {} if (!backendId || backendId === 'all') return {}
return { folder } return { backendId }
} }
function matchesActiveFolder(key: string) { function listQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim() return storageQueryParams()
if (!folder || folder === 'all') return true }
return key.startsWith(`${folder}/`)
function matchesActiveStorage(item: ImageItem) {
const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return true
return item.storage?.id === backendId
}
async function loadStorageBackendOptions() {
try {
const data = await $fetch<{
backends: Array<{ id: string, name: string, type: string }>
}>('/api/storage-backends', {
credentials: 'include'
})
storageBackendOptions.value = data.backends.map(backend => ({
id: backend.id,
name: backend.name
}))
} catch {
storageBackendOptions.value = []
}
} }
async function fetchTotal() { async function fetchTotal() {
@@ -35,11 +56,11 @@ export function useImageList() {
try { try {
const data = await $fetch<{ total: number }>('/api/images/count', { const data = await $fetch<{ total: number }>('/api/images/count', {
credentials: 'include', credentials: 'include',
query: folderQueryParams() query: listQueryParams()
}) })
folderTotal.value = data.total totalCount.value = data.total
} catch { } catch {
folderTotal.value = null totalCount.value = null
} finally { } finally {
loadingTotal.value = false loadingTotal.value = false
} }
@@ -53,7 +74,7 @@ export function useImageList() {
query: { query: {
limit: PAGE_SIZE, limit: PAGE_SIZE,
page: targetPage, page: targetPage,
...folderQueryParams() ...listQueryParams()
} }
}) })
@@ -81,7 +102,7 @@ export function useImageList() {
q, q,
limit: PAGE_SIZE, limit: PAGE_SIZE,
page: targetPage, page: targetPage,
...folderQueryParams() ...listQueryParams()
} }
}) })
@@ -101,19 +122,17 @@ export function useImageList() {
await Promise.all([fetchList(1), fetchTotal()]) await Promise.all([fetchList(1), fetchTotal()])
} }
async function initializeList(folder = 'all') { async function initializeList() {
const next = folder.trim() || 'all'
activeFolder.value = next
page.value = 1 page.value = 1
activeSearch.value = '' activeSearch.value = ''
searchQuery.value = '' searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal()]) await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()])
} }
async function setActiveFolder(folder: string) { async function setActiveStorageBackend(backendId: string) {
const next = folder.trim() || 'all' const next = backendId.trim() || 'all'
if (next === activeFolder.value) return if (next === activeStorageBackend.value) return
activeFolder.value = next activeStorageBackend.value = next
page.value = 1 page.value = 1
if (activeSearch.value) { if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()]) await Promise.all([fetchSearch(1), fetchTotal()])
@@ -143,12 +162,12 @@ export function useImageList() {
} }
function prependItems(newItems: ImageItem[]) { function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => matchesActiveFolder(item.key)) const matched = newItems.filter(item => matchesActiveStorage(item))
if (!matched.length || page.value !== 1) return if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value] items.value = [...matched, ...items.value]
total.value += matched.length total.value += matched.length
if (folderTotal.value !== null) { if (totalCount.value !== null) {
folderTotal.value += matched.length totalCount.value += matched.length
} }
} }
@@ -159,22 +178,43 @@ export function useImageList() {
if (removed > 0) { if (removed > 0) {
total.value = Math.max(0, total.value - removed) total.value = Math.max(0, total.value - removed)
} }
if (folderTotal.value !== null && removed > 0) { if (totalCount.value !== null && removed > 0) {
folderTotal.value = Math.max(0, folderTotal.value - removed) totalCount.value = Math.max(0, totalCount.value - removed)
} }
} }
const activeStorageLabel = computed(() => {
if (activeStorageBackend.value === 'all') return ''
return storageBackendOptions.value.find(
backend => backend.id === activeStorageBackend.value
)?.name ?? activeStorageBackend.value
})
const listSummary = computed(() => { const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all' const storageLabel = activeStorageLabel.value
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder if (loadingTotal.value && totalCount.value === null) {
if (loadingTotal.value && folderTotal.value === null) { return storageLabel
return `${folderLabel} · ${t('stats.counting')}` ? `${storageLabel} · ${t('stats.counting')}`
: t('stats.counting')
} }
if (activeSearch.value) { if (activeSearch.value) {
return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value }) if (storageLabel) {
return t('stats.summarySearchStorage', {
q: activeSearch.value,
total: total.value,
storage: storageLabel
})
}
return t('stats.summarySearch', { q: activeSearch.value, total: total.value })
} }
const count = folderTotal.value === null ? total.value : folderTotal.value const count = totalCount.value === null ? total.value : totalCount.value
return t('stats.summaryTotal', { folder: folderLabel, total: count }) if (storageLabel) {
return t('stats.summaryTotalStorage', {
total: count,
storage: storageLabel
})
}
return t('stats.summaryTotal', { total: count })
}) })
return { return {
@@ -183,7 +223,8 @@ export function useImageList() {
totalPages, totalPages,
total, total,
loading, loading,
activeFolder, activeStorageBackend,
storageBackendOptions,
searchQuery, searchQuery,
activeSearch, activeSearch,
listSummary, listSummary,
@@ -192,7 +233,8 @@ export function useImageList() {
fetchTotal, fetchTotal,
refreshList, refreshList,
initializeList, initializeList,
setActiveFolder, setActiveStorageBackend,
loadStorageBackendOptions,
submitSearch, submitSearch,
goToPage, goToPage,
prependItems, prependItems,
+1 -5
View File
@@ -17,8 +17,7 @@ export function useImageUpload() {
async function uploadFiles( async function uploadFiles(
files: File[], files: File[],
folder = 'images', options?: { notify?: boolean }
options?: { notify?: boolean, includeFolder?: boolean }
) { ) {
if (uploading.value) return if (uploading.value) return
@@ -55,9 +54,6 @@ export function useImageUpload() {
})) }))
const formData = new FormData() const formData = new FormData()
if (options?.includeFolder !== false) {
formData.append('folder', folder.trim() || 'images')
}
for (const file of prepared) { for (const file of prepared) {
formData.append('files', file, file.name) formData.append('files', file, file.name)
} }
+5 -10
View File
@@ -3,7 +3,6 @@ const STORAGE_KEY = 'pichost.upload-preferences'
export type CopyFormat = 'url' | 'markdown' | 'html' export type CopyFormat = 'url' | 'markdown' | 'html'
export interface UploadPreferences { export interface UploadPreferences {
defaultFolder: string
compressEnabled: boolean compressEnabled: boolean
autoCopyMarkdown: boolean autoCopyMarkdown: boolean
copyFormat: CopyFormat copyFormat: CopyFormat
@@ -11,7 +10,6 @@ export interface UploadPreferences {
} }
const DEFAULTS: UploadPreferences = { const DEFAULTS: UploadPreferences = {
defaultFolder: 'images',
compressEnabled: false, compressEnabled: false,
autoCopyMarkdown: false, autoCopyMarkdown: false,
copyFormat: 'markdown', copyFormat: 'markdown',
@@ -36,9 +34,6 @@ function readStored(): UploadPreferences {
if (!raw) return { ...DEFAULTS } if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<UploadPreferences> const parsed = JSON.parse(raw) as Partial<UploadPreferences>
return { return {
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
? parsed.defaultFolder.trim()
: DEFAULTS.defaultFolder,
compressEnabled: parsed.compressEnabled === true, compressEnabled: parsed.compressEnabled === true,
autoCopyMarkdown: parsed.autoCopyMarkdown === true, autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat, copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
@@ -55,7 +50,6 @@ function writeStored(prefs: UploadPreferences) {
} }
export function useUploadPreferences() { export function useUploadPreferences() {
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled) const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown) const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat) const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
@@ -64,7 +58,6 @@ export function useUploadPreferences() {
function loadPreferences() { function loadPreferences() {
const stored = readStored() const stored = readStored()
defaultFolder.value = stored.defaultFolder
compressEnabled.value = stored.compressEnabled compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat copyFormat.value = stored.copyFormat
@@ -72,9 +65,12 @@ export function useUploadPreferences() {
loaded.value = true loaded.value = true
} }
if (import.meta.client && !loaded.value) {
loadPreferences()
}
function savePreferences() { function savePreferences() {
writeStored({ writeStored({
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
compressEnabled: compressEnabled.value, compressEnabled: compressEnabled.value,
autoCopyMarkdown: autoCopyMarkdown.value, autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value, copyFormat: copyFormat.value,
@@ -82,14 +78,13 @@ export function useUploadPreferences() {
}) })
} }
watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => { watch([compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
if (loaded.value) { if (loaded.value) {
savePreferences() savePreferences()
} }
}) })
return { return {
defaultFolder,
compressEnabled, compressEnabled,
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
+21 -62
View File
@@ -6,6 +6,7 @@ interface SettingsResponse {
env: { env: {
webpQuality: number webpQuality: number
refererConfigured: boolean refererConfigured: boolean
siteBaseUrl: string
imageBaseUrl: string imageBaseUrl: string
appVersion: string appVersion: string
} }
@@ -32,6 +33,8 @@ const regenerateResultOpen = ref(false)
const newToken = ref('') const newToken = ref('')
const baseUrl = computed(() => { const baseUrl = computed(() => {
const configured = settings.value?.env.siteBaseUrl
if (configured) return configured
if (import.meta.client) { if (import.meta.client) {
return window.location.origin return window.location.origin
} }
@@ -49,34 +52,13 @@ const authHeader = computed(() =>
const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`) const authHeaderFlag = computed(() => `-H 'Auth-Token: ${authHeader.value}'`)
const apiDocs = computed<ApiDocItem[]>(() => { const apiDocs = computed<ApiDocItem[]>(() => {
const uploadDescription = isAdmin.value const uploadDescription = t('api.docs.upload.desc')
? t('api.docs.upload.descAdmin') const uploadCurl = `curl -X POST "${baseUrl.value}/api/images/upload" \\
: t('api.docs.upload.descUser')
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} \\ ${authHeaderFlag.value} \\
-F "image=@./demo.png"` -F "image=@./demo.png"`
const listDescription = isAdmin.value const listDescription = t('api.docs.list.desc')
? t('api.docs.list.descAdmin') const listCurl = `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
: t('api.docs.list.descUser')
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}` ${authHeaderFlag.value}`
return [ return [
@@ -153,10 +135,12 @@ function methodBadgeColor(method: ApiDocItem['method']) {
switch (method) { switch (method) {
case 'GET': case 'GET':
return 'info' as const return 'info' as const
case 'POST':
return 'warning' as const
case 'DELETE': case 'DELETE':
return 'error' as const return 'error' as const
default: default:
return 'primary' as const return 'neutral' as const
} }
} }
@@ -216,15 +200,6 @@ async function confirmRegenerate() {
} }
} }
async function copyCurl(text: string) {
try {
await navigator.clipboard.writeText(text)
toast.add({ title: t('copy.curl'), color: 'success' })
} catch {
toast.add({ title: t('copy.failed'), color: 'error' })
}
}
function closeConfirmRegenerate() { function closeConfirmRegenerate() {
confirmRegenerateOpen.value = false confirmRegenerateOpen.value = false
} }
@@ -240,10 +215,13 @@ onMounted(async () => {
return return
} }
await checkSession() await checkSession()
if (isAuthenticated.value) {
await loadSettings()
}
}) })
watch(isAuthenticated, async (authed) => { watch(isAuthenticated, async (authed, prev) => {
if (authed) { if (authed && prev === false) {
await nextTick() await nextTick()
await loadSettings() await loadSettings()
} else { } else {
@@ -398,18 +376,7 @@ watch(isAuthenticated, async (authed) => {
<p class="mb-2 text-xs font-medium text-muted"> <p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }} {{ t('api.curlExample') }}
</p> </p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950"> <ApiCurlBlock :code="doc.curl" />
<UButton
icon="i-lucide-copy"
:label="t('common.copy')"
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> </div>
</article> </article>
</div> </div>
@@ -425,7 +392,7 @@ watch(isAuthenticated, async (authed) => {
<div class="min-w-0 space-y-2"> <div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<UBadge <UBadge
color="neutral" :color="methodBadgeColor(twikooDoc.method)"
variant="subtle" variant="subtle"
size="sm" size="sm"
> >
@@ -448,18 +415,10 @@ watch(isAuthenticated, async (authed) => {
<p class="mb-2 text-xs font-medium text-muted"> <p class="mb-2 text-xs font-medium text-muted">
{{ t('api.curlExample') }} {{ t('api.curlExample') }}
</p> </p>
<div class="relative overflow-hidden rounded-xl bg-neutral-950"> <ApiCurlBlock
<UButton :code="twikooDoc.curl"
icon="i-lucide-copy" :scrollable="false"
:label="t('common.copy')" />
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>
</div> </div>
</section> </section>
+2 -29
View File
@@ -4,17 +4,14 @@ import type { ImageItem } from '~/types/image'
const { uploading, progressItems, uploadFiles } = useImageUpload() const { uploading, progressItems, uploadFiles } = useImageUpload()
const { const {
defaultFolder,
autoCopyMarkdown, autoCopyMarkdown,
copyFormat, copyFormat,
loadPreferences loadPreferences
} = useUploadPreferences() } = useUploadPreferences()
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus } = useAuth()
= useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t } = useI18n()
const folderOptions = ref<string[]>(['images'])
const sessionItems = ref<ImageItem[]>([]) const sessionItems = ref<ImageItem[]>([])
const deletingKeys = ref<Set<string>>(new Set()) const deletingKeys = ref<Set<string>>(new Set())
const emptySelection = ref(new Set<string>()) const emptySelection = ref(new Set<string>())
@@ -30,22 +27,9 @@ const showProgress = computed(
) )
) )
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() { async function loadPage() {
try { try {
loadPreferences() loadPreferences()
if (isAdmin.value) {
await loadFolders()
}
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
} }
@@ -112,20 +96,9 @@ async function handleUpload(files: File[]) {
} }
try { try {
const uploadTarget = isAdmin.value const result = await uploadFiles(files)
? (defaultFolder.value.trim() || 'images')
: 'images'
const result = await uploadFiles(files, uploadTarget, {
includeFolder: isAdmin.value
})
if (result?.items.length) { if (result?.items.length) {
sessionItems.value = [...result.items, ...sessionItems.value] 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]) { if (autoCopyMarkdown.value && result.items[0]) {
const first = result.items[0] const first = result.items[0]
const text const text
+618
View File
@@ -0,0 +1,618 @@
<script setup lang="ts">
import type { ActivityLogListResponse, ActivityLogStorage, LogAction, LogSource } from '~/types/logs'
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const { formatFileSize } = useFileSize()
const loading = ref(true)
const page = ref(1)
const actionFilter = ref<'all' | LogAction>('all')
const sourceFilter = ref<'all' | LogSource>('all')
const userFilter = ref<'all' | number>('all')
const searchQuery = ref('')
const activeSearch = ref('')
const listData = ref<ActivityLogListResponse | null>(null)
const imageLinkBase = ref('')
const hideFolderInUrl = ref(false)
const pageSize = 10
const summaryLabel = computed(() => {
const summary = listData.value?.summary
if (!summary) return ''
return t('logs.summaryAll', {
total: summary.total,
upload: summary.upload,
delete: summary.delete
})
})
const actionItems = computed(() => [
{ label: t('logs.filterActionAll'), value: 'all' as const },
{ label: t('logs.actionUpload'), value: 'upload' as const },
{ label: t('logs.actionDelete'), value: 'delete' as const }
])
const sourceItems = computed(() => [
{ label: t('logs.filterSourceAll'), value: 'all' as const },
{ label: t('logs.sourceWeb'), value: 'web' as const },
{ label: t('logs.sourceApi'), value: 'api' as const }
])
const userItems = computed(() => {
const items: Array<{ label: string, value: 'all' | number }> = [
{ label: t('logs.filterUserAll'), value: 'all' }
]
for (const user of listData.value?.users ?? []) {
items.push({ label: user.username, value: user.id })
}
return items
})
function formatTime(value: string) {
try {
return new Date(value).toLocaleString(locale.value, { hour12: false })
} catch {
return value
}
}
function formatTimeShort(value: string) {
try {
return new Date(value).toLocaleString(locale.value, {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} catch {
return value
}
}
function folderFromKey(key: string) {
const slash = key.indexOf('/')
return slash > 0 ? key.slice(0, slash) : '(root)'
}
function actionColor(action: LogAction) {
return action === 'upload' ? 'success' : 'error'
}
function actionIcon(action: LogAction) {
return action === 'upload' ? 'i-lucide-upload' : 'i-lucide-trash-2'
}
function sourceLabel(source: LogSource) {
switch (source) {
case 'web':
return t('logs.sourceWeb')
case 'api':
return t('logs.sourceApi')
}
}
function storageIcon(storage: ActivityLogStorage | null) {
return storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
}
function publicPathFromKey(key: string) {
if (!hideFolderInUrl.value) return key
const slash = key.lastIndexOf('/')
return slash >= 0 ? key.slice(slash + 1) : key
}
function imageUrl(key: string) {
const base = imageLinkBase.value.replace(/\/$/, '')
const path = publicPathFromKey(key)
if (!base) return `/${path}`
return `${base}/${path}`
}
async function loadImageLinkBase() {
try {
if (isAdmin.value) {
const data = await $fetch<{ imageBaseUrl: string, hideFolderInUrl: boolean }>('/api/settings', {
credentials: 'include'
})
imageLinkBase.value = data.imageBaseUrl
hideFolderInUrl.value = data.hideFolderInUrl
return
}
const data = await $fetch<{ env: { imageBaseUrl: string, hideFolderInUrl: boolean } }>('/api/user/api-token', {
credentials: 'include'
})
imageLinkBase.value = data.env.imageBaseUrl
hideFolderInUrl.value = data.env.hideFolderInUrl
} catch {
if (import.meta.client) {
imageLinkBase.value = window.location.origin
}
}
}
async function loadLogs() {
loading.value = true
try {
const query: Record<string, string | number> = {
page: page.value,
limit: pageSize
}
if (actionFilter.value !== 'all') {
query.action = actionFilter.value
}
if (sourceFilter.value !== 'all') {
query.source = sourceFilter.value
}
if (isAdmin.value && userFilter.value !== 'all') {
query.userId = userFilter.value
}
if (activeSearch.value) {
query.q = activeSearch.value
}
listData.value = await $fetch<ActivityLogListResponse>('/api/logs', {
credentials: 'include',
query
})
page.value = listData.value.page
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('logs.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
void loadLogs()
}
function submitSearch() {
activeSearch.value = searchQuery.value.trim()
applyFilters()
}
function clearSearch() {
searchQuery.value = ''
activeSearch.value = ''
applyFilters()
}
function handlePageChange(target: number) {
page.value = target
void loadLogs()
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (!isAuthenticated.value) {
await navigateTo('/')
return
}
await loadImageLinkBase()
await loadLogs()
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await loadImageLinkBase()
await loadLogs()
}
})
</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">
{{ t('common.loadingSession') }}
</p>
</div>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else>
<section class="min-w-0 overflow-x-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-4 py-4 sm:px-6">
<div class="flex items-start gap-2">
<UIcon
name="i-lucide-scroll-text"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div>
<h1 class="text-base font-semibold">
{{ t('logs.pageTitle') }}
</h1>
<p class="mt-0.5 text-xs text-muted">
{{ isAdmin ? t('logs.pageSubtitleAdmin') : t('logs.pageSubtitle') }}
</p>
</div>
</div>
<UButton
icon="i-lucide-refresh-cw"
variant="ghost"
color="neutral"
size="sm"
:aria-label="t('common.refresh')"
:loading="loading"
@click="loadLogs"
/>
</div>
<form
class="border-b border-default px-4 py-3 sm:px-6"
@submit.prevent="submitSearch"
>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p
v-if="summaryLabel"
class="text-xs text-muted"
>
{{ summaryLabel }}
</p>
<p
v-else
class="text-xs text-muted"
>
{{ t('stats.counting') }}
</p>
<div class="flex min-w-0 flex-wrap items-center gap-2 sm:justify-end">
<USelect
v-if="isAdmin"
v-model="userFilter"
:items="userItems"
value-key="value"
class="min-w-0 w-full sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="actionFilter"
:items="actionItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-32"
size="sm"
@update:model-value="applyFilters"
/>
<USelect
v-model="sourceFilter"
:items="sourceItems"
value-key="value"
class="min-w-0 w-[calc(50%-0.25rem)] sm:w-36"
size="sm"
@update:model-value="applyFilters"
/>
<div class="flex min-w-0 w-full items-center gap-2 sm:w-auto">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
:placeholder="t('logs.searchPlaceholder')"
class="min-w-0 flex-1 sm:w-40 sm:flex-none"
size="sm"
:disabled="loading"
/>
<UButton
type="submit"
icon="i-lucide-search"
size="sm"
class="shrink-0"
:aria-label="t('common.search')"
:loading="loading"
>
<span class="hidden sm:inline">{{ t('common.search') }}</span>
</UButton>
<UButton
v-if="activeSearch"
type="button"
icon="i-lucide-x"
size="sm"
variant="ghost"
color="neutral"
class="shrink-0"
:aria-label="t('stats.clear')"
@click="clearSearch"
>
<span class="hidden sm:inline">{{ t('stats.clear') }}</span>
</UButton>
</div>
</div>
</div>
</form>
<div
v-if="loading"
class="p-4 sm:p-6"
>
<div class="space-y-3 sm:hidden">
<USkeleton
v-for="n in 6"
:key="`mobile-${n}`"
class="h-24 rounded-lg"
/>
</div>
<div class="hidden space-y-2 sm:block">
<USkeleton
v-for="n in 8"
:key="`desktop-${n}`"
class="h-12 rounded-lg"
/>
</div>
</div>
<template v-else-if="listData && listData.items.length">
<div class="min-w-0 sm:hidden">
<div class="divide-y divide-default">
<article
v-for="row in listData.items"
:key="row.id"
class="min-w-0 px-4 py-3.5"
>
<div class="flex items-start justify-between gap-3">
<time class="text-xs text-muted">
{{ formatTimeShort(row.createdAt) }}
</time>
<span class="shrink-0 text-xs tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</span>
</div>
<div class="mt-2 flex min-w-0 max-w-full flex-wrap items-center gap-1.5">
<UBadge
:color="actionColor(row.action)"
variant="subtle"
size="xs"
>
<UIcon
:name="actionIcon(row.action)"
class="mr-1 size-3"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</UBadge>
<UBadge
color="neutral"
variant="subtle"
size="xs"
>
{{ sourceLabel(row.source) }}
</UBadge>
<span
v-if="isAdmin"
class="text-xs text-muted"
>
{{ row.username ?? '—' }}
</span>
<UBadge
v-if="row.storage"
color="neutral"
variant="subtle"
size="xs"
class="max-w-full truncate"
>
<UIcon
:name="storageIcon(row.storage)"
class="mr-1 size-3"
/>
{{ row.storage.name }}
</UBadge>
</div>
<p
class="mt-2 truncate text-sm"
:title="row.originalName"
>
{{ row.originalName }}
</p>
<div class="mt-2 flex min-w-0 items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 flex-1 truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="min-w-0 flex-1 truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
class="shrink-0"
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</article>
</div>
</div>
<div class="hidden overflow-x-auto sm:block">
<table class="min-w-full text-left text-sm">
<thead class="border-b border-default bg-muted/30 text-xs text-muted">
<tr>
<th class="px-4 py-3 font-medium sm:px-6">
{{ t('logs.colTime') }}
</th>
<th
v-if="isAdmin"
class="px-4 py-3 font-medium"
>
{{ t('logs.colUser') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colAction') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colSource') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colStorage') }}
</th>
<th class="hidden px-4 py-3 font-medium md:table-cell">
{{ t('logs.colFile') }}
</th>
<th class="hidden px-4 py-3 font-medium lg:table-cell">
{{ t('logs.colFolder') }}
</th>
<th class="px-4 py-3 font-medium">
{{ t('logs.colSize') }}
</th>
<th class="hidden px-4 py-3 font-medium xl:table-cell">
{{ t('logs.colKey') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<tr
v-for="row in listData.items"
:key="row.id"
class="hover:bg-muted/20"
>
<td class="whitespace-nowrap px-4 py-3 text-xs text-muted sm:px-6">
{{ formatTime(row.createdAt) }}
</td>
<td
v-if="isAdmin"
class="px-4 py-3 text-xs"
>
{{ row.username ?? '—' }}
</td>
<td class="px-4 py-3">
<UBadge
:color="actionColor(row.action)"
variant="subtle"
size="xs"
>
<UIcon
:name="actionIcon(row.action)"
class="mr-1 size-3"
/>
{{ row.action === 'upload' ? t('logs.actionUpload') : t('logs.actionDelete') }}
</UBadge>
</td>
<td class="px-4 py-3">
<UBadge
color="neutral"
variant="subtle"
size="xs"
>
{{ sourceLabel(row.source) }}
</UBadge>
</td>
<td class="px-4 py-3 text-xs">
<span
v-if="row.storage"
class="inline-flex max-w-[8rem] items-center gap-1 truncate"
:title="row.storage.name"
>
<UIcon
:name="storageIcon(row.storage)"
class="size-3.5 shrink-0 opacity-70"
/>
<span class="truncate">{{ row.storage.name }}</span>
</span>
<span
v-else
class="text-muted"
>—</span>
</td>
<td
class="hidden max-w-[12rem] truncate px-4 py-3 md:table-cell"
:title="row.originalName"
>
{{ row.originalName }}
</td>
<td class="hidden px-4 py-3 font-mono text-xs text-muted lg:table-cell">
{{ folderFromKey(row.key) }}
</td>
<td class="whitespace-nowrap px-4 py-3 tabular-nums text-muted">
{{ formatFileSize(row.size) }}
</td>
<td class="hidden px-4 py-3 xl:table-cell">
<div class="flex max-w-[16rem] items-center gap-1">
<a
v-if="row.action === 'upload'"
:href="imageUrl(row.key)"
target="_blank"
rel="noopener noreferrer"
class="truncate font-mono text-xs text-primary hover:underline"
:title="row.key"
>
{{ row.key }}
</a>
<span
v-else
class="truncate font-mono text-xs text-dimmed"
:title="row.key"
>
{{ row.key }}
</span>
<CopyButton
icon="i-lucide-copy"
icon-only
:label="t('common.copy')"
:value="row.key"
:success-title="t('copy.copiedUrl')"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<div
v-else
class="px-4 py-16 text-center text-sm text-muted sm:px-6"
>
{{ t('logs.empty') }}
</div>
<div
v-if="listData && listData.total > 0"
class="border-t border-default px-4 py-4 sm:px-6"
>
<PaginationBar
:page="listData.page"
:total-pages="listData.totalPages"
:total="listData.total"
:unit="t('logs.entriesUnit')"
:loading="loading"
@update:page="handlePageChange"
/>
</div>
</section>
</AppShell>
</div>
</template>
-360
View File
@@ -1,360 +0,0 @@
<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 { t } = useI18n()
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: t('settings.badgeEnv'), color: 'warning' as const }
case 'db':
return { label: t('settings.badgeSaved'), color: 'success' as const }
case 'default':
return { label: t('settings.badgeDefault'), color: 'neutral' as const }
default:
return { label: t('settings.badgeUnset'), color: 'neutral' as const }
}
}
function tokenSourceText(source: SettingSource) {
switch (source) {
case 'env':
return t('settings.tokenEnv')
case 'db':
return t('settings.tokenDb')
default:
return t('settings.tokenNone')
}
}
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: t('settings.envOverride'), 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: t('settings.saved'), color: 'success' })
} catch (error: unknown) {
handlePatchError(error, t('settings.saveFailed'))
} 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: t('settings.loadFailed'), 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">
{{ t('common.loadingSession') }}
</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">
{{ t('settings.title') }}
</h1>
<p class="mt-0.5 text-xs text-muted">
{{ t('settings.subtitle') }}
</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">
{{ t('settings.uploadAccount') }}
</h3>
<div class="space-y-2">
<label class="text-sm">{{ t('settings.defaultFolder') }}</label>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.defaultFolderHint') }}
</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">{{ t('settings.allowRegistration') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.allowRegistrationHint') }}
</span>
</span>
</label>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-semibold">
{{ t('settings.accessControl') }}
</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">
{{ t('settings.refererHint') }}
</p>
<UTextarea
v-model="refererDraft"
:placeholder="t('settings.refererPlaceholder')"
: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">
{{ t('settings.linkGeneration') }}
</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">
{{ t('settings.imageBaseUrlHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="text-xs text-muted"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</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>
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource), version: settings.appVersion }) }}
<template v-if="hasServerChanges">
· <span class="text-warning">{{ t('settings.unsavedChanges') }}</span>
</template>
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<UButton
:label="t('settings.manageToken')"
icon="i-lucide-external-link"
variant="outline"
color="neutral"
size="sm"
to="/api"
/>
<UButton
:label="t('common.save')"
icon="i-lucide-save"
size="sm"
:loading="savingServer"
:disabled="!hasServerChanges"
@click="saveServerSettings"
/>
</div>
</div>
</section>
</AppShell>
</div>
</template>
+592
View File
@@ -0,0 +1,592 @@
<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
siteBaseUrl: string
siteBaseUrlConfigured: string
siteBaseUrlSource: SettingSource
domainSeparation: boolean
hideFolderInUrl: boolean
hideFolderInUrlSource: SettingSource
storageUseDatePath: boolean
storageUseDatePathSource: SettingSource
allowRegistration: boolean
appVersion: string
}
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const { t, locale } = useI18n()
const domainSeparationDocUrl = computed(() =>
locale.value === 'en'
? 'https://o96u.github.io/PicHost/en/guide/domain-separation'
: 'https://o96u.github.io/PicHost/guide/domain-separation'
)
const settings = ref<SettingsResponse | null>(null)
const savingServer = ref(false)
const refererDraft = ref('')
const siteBaseUrlDraft = ref('')
const imageBaseUrlDraft = ref('')
const hideFolderInUrlDraft = ref(false)
const storageUseDatePathDraft = ref(true)
const allowRegistrationDraft = ref(false)
const domainSeparationDraft = ref(false)
interface ReleaseCheckResponse {
currentVersion: string
latestVersion: string | null
updateAvailable: boolean
releaseUrl: string | null
}
const releaseCheck = ref<ReleaseCheckResponse | null>(null)
const hasServerChanges = computed(() => {
if (!settings.value) return false
return refererDraft.value !== settings.value.allowedRefererHosts
|| domainSeparationDraft.value !== settings.value.domainSeparation
|| siteBaseUrlDraft.value !== (settings.value.domainSeparation ? settings.value.siteBaseUrlConfigured : '')
|| imageBaseUrlDraft.value !== settings.value.imageBaseUrlConfigured
|| hideFolderInUrlDraft.value !== settings.value.hideFolderInUrl
|| storageUseDatePathDraft.value !== settings.value.storageUseDatePath
|| allowRegistrationDraft.value !== settings.value.allowRegistration
})
function sourceBadge(source: SettingSource | WebpQualitySource) {
switch (source) {
case 'env':
return { label: t('settings.badgeEnv'), color: 'warning' as const }
case 'db':
return { label: t('settings.badgeSaved'), color: 'success' as const }
case 'default':
return { label: t('settings.badgeDefault'), color: 'neutral' as const }
default:
return { label: t('settings.badgeUnset'), color: 'neutral' as const }
}
}
function tokenSourceText(source: SettingSource) {
switch (source) {
case 'env':
return t('settings.tokenEnv')
case 'db':
return t('settings.tokenDb')
default:
return t('settings.tokenNone')
}
}
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
siteBaseUrlDraft.value = data.domainSeparation ? data.siteBaseUrlConfigured : ''
imageBaseUrlDraft.value = data.imageBaseUrlConfigured
hideFolderInUrlDraft.value = data.hideFolderInUrl
storageUseDatePathDraft.value = data.storageUseDatePath
allowRegistrationDraft.value = data.allowRegistration
domainSeparationDraft.value = data.domainSeparation
}
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: t('settings.envOverride'), color: 'warning' })
void loadSettings()
return
}
toast.add({ title: fallback, color: 'error' })
}
function onDomainSeparationDraftChange(next: boolean | 'indeterminate') {
const enabled = next === true
domainSeparationDraft.value = enabled
if (!enabled) {
siteBaseUrlDraft.value = ''
return
}
if (!siteBaseUrlDraft.value && import.meta.client) {
siteBaseUrlDraft.value = window.location.origin
}
}
async function saveServerSettings() {
if (!isAuthenticated.value || !hasServerChanges.value) return
savingServer.value = true
try {
await patchSettings({
allowedRefererHosts: refererDraft.value,
siteBaseUrl: domainSeparationDraft.value ? siteBaseUrlDraft.value : '',
imageBaseUrl: imageBaseUrlDraft.value,
hideFolderInUrl: hideFolderInUrlDraft.value,
storageUseDatePath: storageUseDatePathDraft.value,
allowRegistration: allowRegistrationDraft.value
})
toast.add({ title: t('settings.saved'), color: 'success' })
} catch (error: unknown) {
handlePatchError(error, t('settings.saveFailed'))
} finally {
savingServer.value = false
}
}
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: t('settings.loadFailed'), color: 'error' })
}
}
}
async function checkLatestRelease() {
try {
releaseCheck.value = await $fetch<ReleaseCheckResponse>('/api/version/latest', {
credentials: 'include'
})
} catch {
releaseCheck.value = null
}
}
async function loadPage() {
if (!isAdmin.value) {
await navigateTo('/')
return
}
await Promise.all([loadSettings(), checkLatestRelease()])
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (isAuthenticated.value && !isAdmin.value) {
await navigateTo('/')
return
}
if (isAuthenticated.value && isAdmin.value) {
await loadPage()
}
})
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
if (!isAdmin.value) {
await navigateTo('/')
return
}
await nextTick()
await loadPage()
} else {
settings.value = null
releaseCheck.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">
{{ t('common.loadingSession') }}
</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">
{{ t('settings.title') }}
</h1>
<p class="mt-0.5 text-xs text-muted">
{{ t('settings.subtitle') }}
</p>
</div>
</div>
<div
v-if="settings"
class="p-5 sm:p-6"
>
<div class="space-y-5">
<div class="grid min-w-0 gap-5 lg:grid-cols-2 lg:items-stretch">
<div class="flex h-full min-w-0 flex-col space-y-3 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold">
{{ t('settings.featureToggles') }}
</h3>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
v-model="allowRegistrationDraft"
class="mt-0.5 shrink-0"
/>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t('settings.allowRegistration') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.allowRegistrationHint') }}
</span>
</span>
</label>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="storageUseDatePathDraft"
class="mt-0.5 shrink-0"
:disabled="settings.storageUseDatePathSource === 'env'"
@update:model-value="(v) => storageUseDatePathDraft = v === true"
/>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
{{ t('settings.storageUseDatePath') }}
<UBadge
v-if="settings.storageUseDatePathSource === 'env'"
:color="sourceBadge(settings.storageUseDatePathSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.storageUseDatePathSource).label }}
</UBadge>
</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.storageUseDatePathHint') }}
</span>
</span>
</label>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="hideFolderInUrlDraft"
class="mt-0.5 shrink-0"
:disabled="settings.hideFolderInUrlSource === 'env'"
@update:model-value="(v) => hideFolderInUrlDraft = v === true"
/>
<span class="min-w-0">
<span class="flex flex-wrap items-center gap-2 text-sm font-medium">
{{ t('settings.hideFolderInUrl') }}
<UBadge
v-if="settings.hideFolderInUrlSource === 'env'"
:color="sourceBadge(settings.hideFolderInUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.hideFolderInUrlSource).label }}
</UBadge>
</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('settings.hideFolderInUrlHint') }}
</span>
</span>
</label>
</div>
<div class="flex h-full min-w-0 flex-col space-y-4 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold">
{{ t('settings.domainSettings') }}
</h3>
<label class="flex cursor-pointer items-start gap-2.5 rounded-lg border border-default/50 bg-default/40 p-3">
<UCheckbox
:model-value="domainSeparationDraft"
class="mt-0.5 shrink-0"
@update:model-value="onDomainSeparationDraftChange"
/>
<span class="min-w-0">
<span class="block text-sm font-medium">{{ t('setup.domainSeparation') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('setup.domainSeparationHint') }}
</span>
</span>
</label>
<p
v-if="domainSeparationDraft"
class="rounded-lg border border-warning/25 bg-warning/5 px-3 py-2.5 text-xs leading-relaxed text-warning"
>
{{ t('setup.domainSeparationProxyHint') }}
<a
:href="domainSeparationDocUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{{ t('setup.domainSeparationProxyExample') }}
</a>
</p>
<div
v-if="domainSeparationDraft"
class="grid gap-4 sm:grid-cols-2"
>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.siteBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.siteBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.siteBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.siteBaseUrlHint') }}
</p>
<UInput
v-model="siteBaseUrlDraft"
:placeholder="t('settings.siteBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.siteBaseUrl"
class="truncate text-xs text-muted"
:title="settings.siteBaseUrl"
>
{{ t('settings.siteBaseUrlActive', { url: settings.siteBaseUrl }) }}
</p>
</div>
<div class="space-y-1.5">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="truncate text-xs text-muted"
:title="settings.imageBaseUrl"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
</div>
<div
v-else
class="space-y-1.5"
>
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.imageBaseUrl') }}</label>
<UBadge
:color="sourceBadge(settings.imageBaseUrlSource).color"
variant="subtle"
size="xs"
>
{{ sourceBadge(settings.imageBaseUrlSource).label }}
</UBadge>
</div>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.imageBaseUrlSingleHint') }}
</p>
<UInput
v-model="imageBaseUrlDraft"
:placeholder="t('settings.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
<p
v-if="settings.imageBaseUrl"
class="truncate text-xs text-muted"
:title="settings.imageBaseUrl"
>
{{ t('settings.imageBaseUrlActive', { url: settings.imageBaseUrl }) }}
</p>
</div>
</div>
</div>
<div class="settings-referer-section space-y-1.5 rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold">
{{ t('settings.accessControl') }}
</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">
{{ t('settings.refererHint') }}
</p>
<UTextarea
v-model="refererDraft"
:placeholder="t('settings.refererPlaceholder')"
:rows="4"
:autoresize="false"
class="settings-referer-field w-full font-mono text-sm"
/>
</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-col gap-3 border-t border-default px-5 py-4 sm:flex-row sm:items-center sm:justify-between 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 class="min-w-0">
{{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }}
<a
:href="releaseCheck?.updateAvailable && releaseCheck.releaseUrl
? releaseCheck.releaseUrl
: 'https://github.com/O96u/PicHost/releases'"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-0.5 underline-offset-2 hover:underline"
:class="releaseCheck?.updateAvailable ? 'text-warning' : 'text-primary'"
:title="t('settings.viewReleases')"
>
v{{ settings.appVersion }}
<UIcon
name="i-lucide-external-link"
class="size-3 opacity-70"
/>
</a>
<template v-if="releaseCheck?.updateAvailable && releaseCheck.latestVersion">
·
<a
:href="releaseCheck.releaseUrl || 'https://github.com/O96u/PicHost/releases'"
target="_blank"
rel="noopener noreferrer"
class="text-warning hover:underline"
>
{{ t('settings.updateAvailable', { version: `v${releaseCheck.latestVersion}` }) }}
</a>
</template>
<template v-if="hasServerChanges">
· <span class="text-warning">{{ t('settings.unsavedChanges') }}</span>
</template>
</span>
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<UButton
:label="t('settings.manageToken')"
icon="i-lucide-external-link"
variant="outline"
color="neutral"
size="sm"
to="/api"
/>
<UButton
:label="t('common.save')"
icon="i-lucide-save"
size="sm"
:loading="savingServer"
:disabled="!hasServerChanges"
@click="saveServerSettings"
/>
</div>
</div>
</section>
</AppShell>
</div>
</template>
<style scoped>
.settings-referer-section {
min-width: 0;
}
.settings-referer-field :deep(textarea),
.settings-referer-field :deep(> div) {
box-sizing: border-box;
width: 100%;
min-width: 0;
}
.settings-referer-field :deep(textarea) {
min-height: 5.5rem;
resize: none;
}
</style>
+80 -9
View File
@@ -7,7 +7,13 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const { setup, migrate, fetchStatus } = useAuth() const { setup, migrate, fetchStatus } = useAuth()
const toast = useToast() const toast = useToast()
const { t } = useI18n() const { t, locale } = useI18n()
const domainSeparationDocUrl = computed(() =>
locale.value === 'en'
? 'https://o96u.github.io/PicHost/en/guide/domain-separation'
: 'https://o96u.github.io/PicHost/guide/domain-separation'
)
const needsMigration = ref(false) const needsMigration = ref(false)
const isMigrate = computed(() => needsMigration.value || route.query.migrate === '1') const isMigrate = computed(() => needsMigration.value || route.query.migrate === '1')
@@ -16,14 +22,21 @@ const username = ref('')
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const allowRegistration = ref(false) const allowRegistration = ref(false)
const domainSeparation = ref(false)
const siteBaseUrl = ref('')
const imageBaseUrl = ref('')
const loading = ref(false) const loading = ref(false)
const canSubmit = computed(() => const canSubmit = computed(() => {
Boolean(username.value && password.value && confirmPassword.value) if (!username.value || !password.value || !confirmPassword.value) return false
&& password.value === confirmPassword.value if (password.value !== confirmPassword.value) return false
&& isPasswordValid(password.value) if (!isPasswordValid(password.value)) return false
&& AUTH_USERNAME_PATTERN.test(username.value.trim()) if (!AUTH_USERNAME_PATTERN.test(username.value.trim())) return false
) if (domainSeparation.value) {
return Boolean(siteBaseUrl.value.trim() && imageBaseUrl.value.trim())
}
return true
})
onMounted(async () => { onMounted(async () => {
const status = await fetchStatus() const status = await fetchStatus()
@@ -34,6 +47,10 @@ onMounted(async () => {
} }
if (status.initialized && !status.needsMigration) { if (status.initialized && !status.needsMigration) {
await router.replace('/') await router.replace('/')
return
}
if (import.meta.client) {
siteBaseUrl.value = window.location.origin
} }
}) })
@@ -50,7 +67,10 @@ async function submit() {
const payload = { const payload = {
username: username.value.trim(), username: username.value.trim(),
password: password.value, password: password.value,
allowRegistration: allowRegistration.value allowRegistration: allowRegistration.value,
domainSeparation: domainSeparation.value,
siteBaseUrl: domainSeparation.value ? siteBaseUrl.value.trim() : undefined,
imageBaseUrl: domainSeparation.value ? imageBaseUrl.value.trim() : undefined
} }
const result = isMigrate.value const result = isMigrate.value
? await migrate(payload) ? await migrate(payload)
@@ -76,7 +96,7 @@ async function submit() {
<div class="absolute top-4 right-4"> <div class="absolute top-4 right-4">
<AuthPagePreferences /> <AuthPagePreferences />
</div> </div>
<div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:p-8"> <div class="w-full max-w-sm rounded-2xl border border-default bg-elevated p-6 shadow-lg sm:max-w-md sm:p-8">
<div class="mb-8 space-y-3 text-center"> <div class="mb-8 space-y-3 text-center">
<div class="mx-auto inline-flex items-center justify-center"> <div class="mx-auto inline-flex items-center justify-center">
<img <img
@@ -164,6 +184,57 @@ async function submit() {
{{ t('setup.allowRegistration') }} {{ t('setup.allowRegistration') }}
</label> </label>
<div class="space-y-3 rounded-xl border border-default bg-muted/20 p-4">
<label class="flex cursor-pointer items-start gap-2.5">
<UCheckbox
v-model="domainSeparation"
class="mt-0.5"
/>
<span>
<span class="block text-sm font-medium">{{ t('setup.domainSeparation') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-muted">
{{ t('setup.domainSeparationHint') }}
</span>
</span>
</label>
<template v-if="domainSeparation">
<p class="text-xs leading-relaxed text-warning">
{{ t('setup.domainSeparationProxyHint') }}
<a
:href="domainSeparationDocUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
{{ t('setup.domainSeparationProxyExample') }}
</a>
</p>
<div class="space-y-2">
<label class="text-sm">{{ t('setup.siteBaseUrl') }}</label>
<p class="text-xs text-muted">
{{ t('setup.siteBaseUrlHint') }}
</p>
<UInput
v-model="siteBaseUrl"
:placeholder="t('setup.siteBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm">{{ t('setup.imageBaseUrl') }}</label>
<p class="text-xs text-muted">
{{ t('setup.imageBaseUrlHint') }}
</p>
<UInput
v-model="imageBaseUrl"
:placeholder="t('setup.imageBaseUrlPlaceholder')"
class="w-full font-mono text-sm"
/>
</div>
</template>
</div>
<UButton <UButton
type="submit" type="submit"
:label="isMigrate ? t('setup.migrateSubmit') : t('setup.initSubmit')" :label="isMigrate ? t('setup.migrateSubmit') : t('setup.initSubmit')"
+86 -111
View File
@@ -12,17 +12,7 @@ interface StatsResponse {
bySource: { bySource: {
web: number web: number
api: number api: number
twikoo: number
} }
byFolder: Array<{
folder: string
count: number
bytes: number
}>
byFolderUploads: Array<{
folder: string
count: number
}>
} }
const { const {
@@ -38,10 +28,13 @@ const {
fetchList, fetchList,
fetchSearch, fetchSearch,
fetchTotal, fetchTotal,
setActiveFolder,
submitSearch, submitSearch,
goToPage, goToPage,
removeItems removeItems,
activeStorageBackend,
storageBackendOptions,
setActiveStorageBackend,
loadStorageBackendOptions
} = useImageList() } = useImageList()
const { formatFileSize } = useFileSize() const { formatFileSize } = useFileSize()
@@ -59,51 +52,41 @@ const deleteModalOpen = ref(false)
const deleteTargetKeys = ref<string[]>([]) const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false) const batchDeleting = ref(false)
const showScrollTop = ref(false) const showScrollTop = ref(false)
const currentFolder = ref('all') const currentStorage = ref('all')
const folderOptions = ref<string[]>(['images'])
const folderSelectItems = computed(() => [ const storageSelectItems = computed(() => [
{ label: t('stats.allFolders'), value: 'all' }, { label: t('stats.filterStorageAll'), value: 'all' },
...folderOptions.value.map(folder => ({ label: folder, value: folder })) ...storageBackendOptions.value.map(backend => ({
label: backend.name,
value: backend.id
}))
]) ])
const selectedCount = computed(() => selectedKeys.value.size) const selectedCount = computed(() => selectedKeys.value.size)
const FOLDER_CHART_COLORS = [ const SOURCE_CHART_COLORS = [
'var(--ui-primary)', 'var(--ui-primary)',
'#10b981', '#10b981'
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4'
] ]
const folderUploadItems = computed(() => const sourceUploadItems = computed(() => {
(stats.value?.byFolderUploads ?? []).map((item, index) => ({ const bySource = stats.value?.bySource
key: item.folder, if (!bySource) return []
label: item.folder, return [
count: item.count, { key: 'web', label: t('stats.sourceWeb'), count: bySource.web, color: SOURCE_CHART_COLORS[0]! },
color: FOLDER_CHART_COLORS[index % FOLDER_CHART_COLORS.length]! { key: 'api', label: t('stats.sourceApi'), count: bySource.api, color: SOURCE_CHART_COLORS[1]! }
})) ]
})
const sourceUploadTotal = computed(() =>
sourceUploadItems.value.reduce((sum, item) => sum + item.count, 0)
) )
const donutSegments = computed(() => function sourcePercent(count: number) {
folderUploadItems.value.map(item => ({ const total = sourceUploadTotal.value
label: item.label, if (!total) return 0
value: item.count, return Math.round((count / total) * 100)
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 statCards = computed(() => {
const s = stats.value const s = stats.value
@@ -130,17 +113,6 @@ function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' }) 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() { async function fetchStats() {
stats.value = await $fetch<StatsResponse>('/api/stats', { stats.value = await $fetch<StatsResponse>('/api/stats', {
credentials: 'include' credentials: 'include'
@@ -165,7 +137,8 @@ async function refreshGallery() {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
selectedKeys.value = new Set() selectedKeys.value = new Set()
try { try {
await Promise.all([refreshList(), loadFolders()]) await Promise.all([refreshList(), loadStorageBackendOptions()])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
} }
@@ -188,8 +161,9 @@ async function refreshAll() {
await Promise.all([ await Promise.all([
fetchStats(), fetchStats(),
reloadGallery(), reloadGallery(),
loadFolders() loadStorageBackendOptions()
]) ])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
if (isAuthenticated.value) { if (isAuthenticated.value) {
@@ -200,12 +174,12 @@ async function refreshAll() {
} }
} }
async function handleFolderChange(folder: string) { async function handleStorageChange(backendId: string) {
if (!isAuthenticated.value) return if (!isAuthenticated.value) return
currentFolder.value = folder currentStorage.value = backendId
selectedKeys.value = new Set() selectedKeys.value = new Set()
try { try {
await setActiveFolder(folder) await setActiveStorageBackend(backendId)
} catch (error: unknown) { } catch (error: unknown) {
handleAuthError(error) handleAuthError(error)
} }
@@ -277,6 +251,10 @@ async function confirmDelete() {
color: 'success' color: 'success'
}) })
await refreshStats() await refreshStats()
await reloadGallery()
if (items.value.length === 0 && galleryPage.value > 1) {
await goToPage(galleryPage.value - 1)
}
} }
if (failed.length) { if (failed.length) {
@@ -316,6 +294,9 @@ onMounted(async () => {
return return
} }
await checkSession() await checkSession()
if (isAuthenticated.value) {
await refreshAll()
}
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -376,67 +357,60 @@ watch(isAuthenticated, async (authed, prev) => {
/> />
</div> </div>
<section class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <section class="grid gap-3 lg:grid-cols-3 lg:items-stretch">
<div <div class="grid grid-cols-2 gap-3 lg:col-span-2 lg:grid-cols-4">
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">
{{ t('stats.folderDistribution') }}
</h2>
<div <div
v-if="!uploadChartTotal" v-for="item in statCards"
class="py-6 text-center text-xs text-muted" :key="item.label"
class="rounded-xl border border-default bg-elevated p-4"
> >
{{ t('stats.noData') }} <p class="text-xs text-muted">
{{ item.label }}
</p>
<p class="mt-1 text-2xl font-semibold tabular-nums">
{{ item.value }}
</p>
</div> </div>
<StatsDonut
v-else
:segments="donutSegments"
:size="88"
/>
</div> </div>
<div class="rounded-xl border border-default bg-elevated p-4"> <div class="flex h-full min-h-0 flex-col rounded-xl border border-default bg-elevated p-4 lg:col-span-1">
<h2 class="mb-3 text-sm font-medium text-muted"> <h2 class="shrink-0 text-sm font-medium text-muted">
{{ t('stats.folderUsage') }} {{ t('stats.sourceDistribution') }}
</h2> </h2>
<div <div
v-if="!folderStats.length" v-if="!sourceUploadTotal"
class="py-6 text-center text-xs text-muted" class="flex flex-1 items-center justify-center text-center text-xs text-muted"
> >
{{ t('stats.noData') }} {{ t('stats.noData') }}
</div> </div>
<ul <ul
v-else v-else
class="space-y-2.5" class="mt-4 flex min-h-0 flex-1 flex-col"
> >
<li <li
v-for="item in folderStats" v-for="item in sourceUploadItems"
:key="item.folder" :key="item.key"
class="flex flex-1 flex-col justify-center gap-2 py-1"
> >
<div class="mb-1 flex items-center justify-between gap-2 text-xs"> <div class="flex items-center justify-between gap-2 text-xs">
<span class="font-medium">{{ item.folder }}</span> <span class="flex min-w-0 items-center gap-1.5">
<span class="tabular-nums text-muted"> <span
{{ t('stats.folderCount', { count: item.count, size: formatFileSize(item.bytes) }) }} class="size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
<span class="truncate">{{ item.label }}</span>
</span>
<span class="shrink-0 tabular-nums text-muted">
{{ item.count }} ({{ sourcePercent(item.count) }}%)
</span> </span>
</div> </div>
<div class="h-1.5 overflow-hidden rounded-full bg-muted/50"> <div class="h-2 rounded-full bg-muted/40">
<div <div
class="h-full rounded-full bg-primary/70" class="h-full rounded-full transition-[width]"
:style="{ width: `${Math.max(folderPercent(item.bytes), item.bytes ? 4 : 0)}%` }" :style="{
width: `${sourcePercent(item.count)}%`,
backgroundColor: item.color
}"
/> />
</div> </div>
</li> </li>
@@ -471,12 +445,12 @@ watch(isAuthenticated, async (authed, prev) => {
{{ t('stats.selected', { n: selectedCount }) }} {{ t('stats.selected', { n: selectedCount }) }}
</UBadge> </UBadge>
<USelect <USelect
v-model="currentFolder" v-model="currentStorage"
:items="folderSelectItems" :items="storageSelectItems"
class="w-[5.5rem] shrink-0 sm:w-32" class="w-[6.5rem] shrink-0 sm:w-36"
size="sm" size="sm"
:aria-label="t('stats.folder')" :aria-label="t('stats.storage')"
@update:model-value="handleFolderChange" @update:model-value="handleStorageChange"
/> />
<UInput <UInput
v-model="searchQuery" v-model="searchQuery"
@@ -539,6 +513,7 @@ watch(isAuthenticated, async (authed, prev) => {
<ImageGrid <ImageGrid
v-else v-else
:show-key="false" :show-key="false"
show-storage
:items="items" :items="items"
:selected-keys="selectedKeys" :selected-keys="selectedKeys"
:deleting-keys="deletingKeys" :deleting-keys="deletingKeys"
+352
View File
@@ -0,0 +1,352 @@
<script setup lang="ts">
import type { StorageFormPayload } from '~/components/storage/StorageBackendForm.vue'
import type { StorageBackendItem, StorageListResponse } from '~/types/storage'
const { isChecking, isAuthenticated, checkSession, handleAuthError, fetchStatus, isAdmin } = useAuth()
const toast = useToast()
const { t } = useI18n()
const loading = ref(true)
const busyId = ref<string | null>(null)
const storageData = ref<StorageListResponse | null>(null)
const formOpen = ref(false)
const formMode = ref<'create' | 'edit'>('create')
const editingBackend = ref<StorageBackendItem | null>(null)
const formInstanceKey = ref('create')
const savingForm = ref(false)
const deleteOpen = ref(false)
const deleteTarget = ref<StorageBackendItem | null>(null)
const deleteConfirmName = ref('')
const deleting = ref(false)
async function loadStorage() {
loading.value = true
try {
storageData.value = await $fetch<StorageListResponse>('/api/admin/storage', {
credentials: 'include'
})
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: t('storage.loadFailed'), color: 'error' })
}
} finally {
loading.value = false
}
}
const { quotaToBytes } = useFileSize()
function buildPatchBody(payload: StorageFormPayload) {
const quotaBytes = quotaToBytes(payload.quotaValue, payload.quotaUnit)
const body: Record<string, unknown> = {
name: payload.name,
servingMode: payload.servingMode,
publicUrl: payload.publicUrl,
quotaBytes,
config: payload.config
}
if (payload.secrets.accessKeyId || payload.secrets.secretAccessKey) {
body.secrets = payload.secrets
}
return body
}
function openCreate() {
formMode.value = 'create'
editingBackend.value = null
formInstanceKey.value = `create-${Date.now()}`
formOpen.value = true
}
function openEdit(backend: StorageBackendItem) {
formMode.value = 'edit'
editingBackend.value = backend
formInstanceKey.value = backend.id
formOpen.value = true
}
async function onFormSubmit(payload: StorageFormPayload) {
if (!quotaToBytes(payload.quotaValue, payload.quotaUnit)) {
toast.add({ title: t('storage.quotaRequired'), color: 'warning' })
return
}
if (formMode.value === 'create') {
if (!payload.secrets.accessKeyId || !payload.secrets.secretAccessKey) {
toast.add({ title: t('storage.secretsRequired'), color: 'warning' })
return
}
}
savingForm.value = true
try {
const body = buildPatchBody(payload)
if (formMode.value === 'create') {
await $fetch('/api/admin/storage', {
method: 'POST',
credentials: 'include',
body
})
toast.add({ title: t('storage.created'), color: 'success' })
} else if (editingBackend.value) {
await $fetch(`/api/admin/storage/${editingBackend.value.id}`, {
method: 'PATCH',
credentials: 'include',
body
})
toast.add({ title: t('storage.saved'), color: 'success' })
}
formOpen.value = false
await loadStorage()
} catch {
toast.add({ title: t('storage.saveFailed'), color: 'error' })
} finally {
savingForm.value = false
}
}
async function setDefault(backend: StorageBackendItem) {
busyId.value = backend.id
try {
await $fetch(`/api/admin/storage/${backend.id}`, {
method: 'PATCH',
credentials: 'include',
body: { isDefault: true, enabled: true }
})
toast.add({ title: t('storage.defaultUpdated'), color: 'success' })
await loadStorage()
} catch {
toast.add({ title: t('storage.saveFailed'), color: 'error' })
} finally {
busyId.value = null
}
}
async function testBackend(backend: StorageBackendItem) {
busyId.value = backend.id
try {
const result = await $fetch<{ ok: boolean, message: string }>(
`/api/admin/storage/${backend.id}/test`,
{ method: 'POST', credentials: 'include' }
)
toast.add({
title: result.ok ? t('storage.testSuccess') : t('storage.testFailed'),
description: result.message,
color: result.ok ? 'success' : 'error'
})
} catch {
toast.add({ title: t('storage.testFailed'), color: 'error' })
} finally {
busyId.value = null
}
}
async function toggleEnabled(backend: StorageBackendItem) {
busyId.value = backend.id
try {
await $fetch(`/api/admin/storage/${backend.id}`, {
method: 'PATCH',
credentials: 'include',
body: { enabled: !backend.enabled }
})
await loadStorage()
} catch {
toast.add({ title: t('storage.saveFailed'), color: 'error' })
} finally {
busyId.value = null
}
}
function confirmDelete(backend: StorageBackendItem) {
if (backend.usage.count > 0) {
toast.add({ title: t('storage.deleteBlockedImages'), color: 'warning' })
return
}
deleteTarget.value = backend
deleteConfirmName.value = backend.name
deleteOpen.value = true
}
async function executeDelete() {
const target = deleteTarget.value
if (!target) return
deleting.value = true
try {
await $fetch(`/api/admin/storage/${target.id}`, {
method: 'DELETE',
credentials: 'include'
})
toast.add({ title: t('storage.deleted'), color: 'success' })
deleteOpen.value = false
deleteTarget.value = null
await loadStorage()
} catch (error: unknown) {
const message = typeof error === 'object' && error !== null && 'data' in error
? String((error as { data?: { message?: string } }).data?.message ?? '')
: ''
toast.add({
title: message || t('storage.deleteFailed'),
color: 'error'
})
} finally {
deleting.value = false
}
}
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized && !status.legacyMode) {
await navigateTo('/setup')
return
}
await checkSession()
if (!isAuthenticated.value || !isAdmin.value) {
await navigateTo('/')
return
}
await loadStorage()
})
watch(isAuthenticated, async (authed) => {
if (authed && isAdmin.value) {
await loadStorage()
}
})
</script>
<template>
<div class="min-h-screen">
<div
v-if="isChecking"
class="flex min-h-screen items-center justify-center"
>
<UIcon
name="i-lucide-loader-circle"
class="size-8 animate-spin text-muted"
/>
</div>
<AdminLoginGate v-else-if="!isAuthenticated" />
<AppShell v-else-if="isAdmin">
<section class="overflow-hidden rounded-2xl border border-default bg-elevated shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-default px-5 py-4 sm:px-6">
<div class="flex items-start gap-2">
<UIcon
name="i-lucide-hard-drive"
class="mt-0.5 size-5 shrink-0 text-primary"
/>
<div>
<h1 class="text-base font-semibold">
{{ t('storage.pageTitle') }}
</h1>
<p class="mt-0.5 text-xs text-muted">
{{ t('storage.pageSubtitle') }}
</p>
</div>
</div>
<UButton
:label="t('storage.addBackend')"
icon="i-lucide-plus"
size="sm"
:disabled="storageData?.envOverride"
@click="openCreate"
/>
</div>
<div class="p-5 sm:p-6">
<UAlert
v-if="storageData?.envOverride"
color="warning"
variant="subtle"
class="mb-5"
:title="t('storage.envOverride')"
icon="i-lucide-triangle-alert"
/>
<p class="mb-5 text-xs text-muted">
{{ t('storage.switchHint') }}
</p>
<div
v-if="loading"
class="flex justify-center py-16"
>
<UIcon
name="i-lucide-loader-circle"
class="size-6 animate-spin text-muted"
/>
</div>
<div
v-else-if="storageData"
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
>
<StorageBackendCard
v-for="backend in storageData.backends"
:key="backend.id"
:backend="backend"
:env-override="storageData.envOverride"
:busy="busyId === backend.id"
@set-default="setDefault(backend)"
@edit="openEdit(backend)"
@test="testBackend(backend)"
@toggle-enabled="toggleEnabled(backend)"
@delete="confirmDelete(backend)"
/>
</div>
</div>
</section>
</AppShell>
<UModal v-model:open="formOpen">
<template #content>
<div class="p-5 sm:p-6">
<h2 class="mb-4 text-base font-semibold">
{{ formMode === 'create' ? t('storage.addBackend') : t('storage.editBackend') }}
</h2>
<div class="max-h-[75vh] overflow-y-auto pr-1">
<StorageBackendForm
:key="formInstanceKey"
:backend="editingBackend"
:create-mode="formMode === 'create'"
:saving="savingForm"
@submit="onFormSubmit"
@cancel="formOpen = false"
/>
</div>
</div>
</template>
</UModal>
<UModal v-model:open="deleteOpen">
<template #content>
<div class="p-5 sm:p-6">
<h2 class="text-base font-semibold">
{{ t('storage.deleteConfirmTitle') }}
</h2>
<p class="mt-2 text-sm text-muted">
{{ t('storage.deleteConfirmBody', { name: deleteConfirmName }) }}
</p>
<div class="mt-5 flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
variant="outline"
color="neutral"
@click="() => { deleteOpen = false }"
/>
<UButton
:label="t('common.delete')"
color="error"
:loading="deleting"
@click="executeDelete"
/>
</div>
</div>
</template>
</UModal>
</div>
</template>
+6
View File
@@ -36,6 +36,12 @@ export interface ImageItem {
userId: number | null userId: number | null
username: string username: string
} }
/** 图片所在存储后端(统计/管理列表) */
storage?: {
id: string
name: string
type: 'local' | 's3'
}
} }
export interface UploadErrorItem { export interface UploadErrorItem {
+36
View File
@@ -0,0 +1,36 @@
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api'
export interface ActivityLogStorage {
id: string
name: string
type: string
}
export interface ActivityLogItem {
id: number
action: LogAction
key: string
originalName: string
size: number
contentType: string
source: LogSource
userId: number | null
username: string | null
storage: ActivityLogStorage | null
createdAt: string
}
export interface ActivityLogListResponse {
items: ActivityLogItem[]
total: number
page: number
pageSize: number
totalPages: number
summary: {
total: number
upload: number
delete: number
}
users?: Array<{ id: number, username: string }>
}
+39
View File
@@ -0,0 +1,39 @@
export type ServingMode = 'proxy' | 'public'
export interface StorageCapacity {
totalBytes: number | null
usedBytes: number
freeBytes: number | null
percent: number | null
source: 'disk' | 'quota'
}
export interface StorageBackendItem {
id: string
name: string
type: 'local' | 's3'
config: {
endpoint?: string
region?: string
bucket?: string
prefix?: string
forcePathStyle?: boolean
}
secretsMasked: {
accessKeyId?: string
secretAccessKey?: string
}
servingMode: ServingMode
publicUrl: string
quotaBytes: number | null
enabled: boolean
isDefault: boolean
usage: { count: number, bytes: number }
capacity: StorageCapacity
}
export interface StorageListResponse {
backends: StorageBackendItem[]
activeBackendId: string
envOverride: boolean
}
+1
View File
@@ -10,6 +10,7 @@ services:
- "6892:6892" - "6892:6892"
# 首次访问 Web 引导创建管理员,无需预先配置环境变量 # 首次访问 Web 引导创建管理员,无需预先配置环境变量
# 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等 # 可选:environment 中设置 API_UPLOAD_TOKEN、ALLOWED_REFERER_HOSTS 等
# 运维:docker exec pichost reset-password | migrate [--apply]
volumes: volumes:
# 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data) # 冒号左边改成 NAS 上的实际存图目录(飞牛示例:/vol1/1000/pichost-data)
- ./data:/data - ./data:/data
+5 -1
View File
@@ -2,7 +2,11 @@
set -e set -e
if [ "$1" = "reset-password" ]; then if [ "$1" = "reset-password" ]; then
exec node /app/server/cli/reset-password.mjs exec node /app/server/cli/reset-password.mjs "${@:2}"
fi
if [ "$1" = "migrate" ]; then
exec node /app/server/cli/migrate-to-single-images.mjs "${@:2}"
fi fi
exec node /app/.output/server/index.mjs "$@" exec node /app/.output/server/index.mjs "$@"
+129
View File
@@ -0,0 +1,129 @@
import { defineConfig } from 'vitepress'
const docsBase = 'https://o96u.github.io/PicHost'
const zhSidebar = [
{
text: '开始',
items: [
{ text: '文档首页', link: '/guide/' },
{ text: '快速开始', link: '/guide/getting-started' },
{ text: '本地开发', link: '/guide/local-dev' }
]
},
{
text: '部署与配置',
items: [
{ text: '环境变量', link: '/guide/configuration' },
{ text: '反向代理', link: '/guide/reverse-proxy' },
{ text: '双域名分离', link: '/guide/domain-separation' }
]
},
{
text: '使用',
items: [
{ text: '存储', link: '/guide/storage' },
{ text: '用户与权限', link: '/guide/users-and-permissions' },
{ text: 'API', link: '/guide/api' },
{ text: 'Twikoo', link: '/guide/twikoo' }
]
},
{
text: '升级',
items: [
{ text: 'v1.2 迁移', link: '/guide/migration' },
{ text: '常见问题', link: '/guide/faq' }
]
}
]
const enSidebar = [
{
text: 'Getting started',
items: [
{ text: 'Overview', link: '/en/guide/' },
{ text: 'Quick start', link: '/en/guide/getting-started' },
{ text: 'Local development', link: '/en/guide/local-dev' }
]
},
{
text: 'Deploy & configure',
items: [
{ text: 'Environment variables', link: '/en/guide/configuration' },
{ text: 'Reverse proxy', link: '/en/guide/reverse-proxy' },
{ text: 'Dual-domain separation', link: '/en/guide/domain-separation' }
]
},
{
text: 'Usage',
items: [
{ text: 'Storage', link: '/en/guide/storage' },
{ text: 'Users & permissions', link: '/en/guide/users-and-permissions' },
{ text: 'API', link: '/en/guide/api' },
{ text: 'Twikoo', link: '/en/guide/twikoo' }
]
},
{
text: 'Upgrade',
items: [
{ text: 'v1.2 migration', link: '/en/guide/migration' },
{ text: 'FAQ', link: '/en/guide/faq' }
]
}
]
export default defineConfig({
base: '/PicHost/',
title: 'PicHost',
description: '自托管图床文档 · Self-hosted image hosting docs',
head: [['link', { rel: 'icon', href: '/PicHost/favicon.ico' }]],
locales: {
root: {
label: '简体中文',
lang: 'zh-CN',
themeConfig: {
nav: [
{ text: '指南', link: '/guide/getting-started' },
{ text: 'API', link: '/guide/api' },
{ text: 'GitHub', link: 'https://github.com/O96u/PicHost' }
],
sidebar: zhSidebar,
docFooter: { prev: '上一页', next: '下一页' },
outline: { label: '本页目录' },
lastUpdated: { text: '最后更新' },
returnToTopLabel: '回到顶部',
sidebarMenuLabel: '菜单',
darkModeSwitchLabel: '主题',
langMenuLabel: '语言'
}
},
en: {
label: 'English',
lang: 'en-US',
link: '/en/',
themeConfig: {
nav: [
{ text: 'Guide', link: '/en/guide/getting-started' },
{ text: 'API', link: '/en/guide/api' },
{ text: 'GitHub', link: 'https://github.com/O96u/PicHost' }
],
sidebar: enSidebar,
docFooter: { prev: 'Previous', next: 'Next' },
outline: { label: 'On this page' },
lastUpdated: { text: 'Last updated' },
returnToTopLabel: 'Back to top',
sidebarMenuLabel: 'Menu',
darkModeSwitchLabel: 'Appearance',
langMenuLabel: 'Language'
}
}
},
themeConfig: {
socialLinks: [{ icon: 'github', link: 'https://github.com/O96u/PicHost' }],
search: { provider: 'local' },
editLink: {
pattern: `${docsBase}/edit/main/docs-site/:path`,
text: '在 GitHub 上编辑此页'
}
}
})
+6
View File
@@ -0,0 +1,6 @@
:root {
--vp-c-brand-1: #22c55e;
--vp-c-brand-2: #16a34a;
--vp-c-brand-3: #15803d;
--vp-c-brand-soft: rgba(34, 197, 94, 0.14);
}
+4
View File
@@ -0,0 +1,4 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
export default DefaultTheme
+16
View File
@@ -0,0 +1,16 @@
import { cpSync, existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const src = join(root, 'docs', 'screenshots')
const dest = join(root, 'docs-site', 'public', 'screenshots')
if (!existsSync(src)) {
console.error(`Screenshot source missing: ${src}`)
process.exit(1)
}
mkdirSync(dest, { recursive: true })
cpSync(src, dest, { recursive: true })
console.log(`Copied screenshots to ${dest}`)
+99
View File
@@ -0,0 +1,99 @@
# API
PicHost exposes REST endpoints and a Twikoo-compatible upload. After deployment, open the in-app **API** page for your token and copyable cURL snippets.
## Authentication
All REST calls use:
```http
Auth-Token: YOUR_TOKEN
```
| Token type | How to obtain | Notes |
| ---------- | ------------- | ----- |
| **Global** | Admin **API** page or `API_UPLOAD_TOKEN` env | Env wins and blocks UI regenerate |
| **Personal** | Each user’s **API** page | Uploads belong to that user |
Form uploads may use field `token` (Twikoo protocol).
## Endpoints
### 1. Upload images
`POST /api/images/upload`
Upload one or more images via `image`; also accepts `file`, `files`. Stored under `images/`.
```bash
curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png"
```
### 2. List images
`GET /api/images`
Paginated gallery. `limit` default 20, max 100.
```bash
curl "https://admin.example.com/api/images?limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 3. Search images
`GET /api/images/search`
Search by filename or path; `q` is required.
```bash
curl "https://admin.example.com/api/images/search?q=demo&limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 4. Delete image
`DELETE /api/images`
Delete by `key` (storage path, e.g. `images/2026/08/xxx.webp`).
```bash
curl -X DELETE "https://admin.example.com/api/images?key=images/2026/08/xxxx.webp" \
-H "Auth-Token: YOUR_TOKEN"
```
### 5. Batch delete
`POST /api/images/batch-delete`
JSON body: `{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}`
```bash
curl -X POST "https://admin.example.com/api/images/batch-delete" \
-H "Auth-Token: YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'
```
## Errors
REST responses are JSON with `code` and `message` (`UNAUTHORIZED`, `FORBIDDEN`, `INVALID_REQUEST`, etc.). Twikoo uses EasyImage-compatible JSON.
## Visibility
- User token: list/search/delete own images only
- Admin token: all images
- Global token uploads belong to admin
See [Users & permissions](./users-and-permissions.md).
## Twikoo
`POST /api/index.php` — [Twikoo](./twikoo.md).
## See also
- [Environment variables](./configuration.md)
- [Reverse proxy](./reverse-proxy.md)
+101
View File
@@ -0,0 +1,101 @@
# Environment variables
Configure PicHost via environment variables (Docker / `.env`) or the in-app **Settings** page. Most values prefer **SQLite** when saved; some env vars override or lock UI editing.
## Priority overview
| Setting | Priority (high → low) | Env overrides UI |
| ------- | --------------------- | ---------------- |
| API upload token | Env → SQLite | **Yes** (cannot regenerate in UI) |
| WebP quality | SQLite → env → default 80 | No |
| Referer whitelist | SQLite → env | No |
| Site / image base URL | SQLite → env → request origin | No |
| Hide `images/` prefix | SQLite → env → default false | No |
| Date-based paths | SQLite → env → default true | No |
| Global auto-delete days | SQLite → env → default 0 | No |
## Variables
### `API_UPLOAD_TOKEN`
Token for external uploads (Twikoo, userscripts, blogs). Header: `Auth-Token: <token>`; form field `token` still works.
- Generate in **API** page when unset
- **When set in env, it wins** — UI shows “overridden by environment” and cannot regenerate
- Alias: `NUXT_API_UPLOAD_TOKEN`
### `ALLOWED_REFERER_HOSTS`
Comma-separated Referer whitelist. Own hostnames are allowed automatically. Optional; configurable in Settings.
### `SITE_BASE_URL` / `IMAGE_BASE_URL`
- **Site URL**: admin UI, API, Twikoo upload
- **Image URL**: copied links and public image URLs
Single-domain: set `IMAGE_BASE_URL` only or leave empty (request origin). Dual-domain: hostnames must differ — see [Dual-domain separation](./domain-separation.md). Values must match proxy `server_name`; do not use `localhost`, IP, or unlisted hostnames for production admin access.
### `HIDE_FOLDER_IN_URL`
`true` hides the `images/` prefix in public URLs.
### `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT`
- `true` or `STORAGE_LAYOUT=date` (default): `images/YYYY/MM/id.webp`
- `false` or `STORAGE_LAYOUT=flat`: `images/id.webp`
Also toggled in Settings.
### `DATA_DIR`
Root for images and SQLite. Default `./data`; `/data` in container.
### `WEBP_QUALITY`
Server WebP quality 1–100, default **80**.
### `AUTO_DELETE_DAYS`
Delete images older than N days globally; `0` disables. Affects **new uploads after enable** only. Also in Settings.
### `DEV_BYPASS_ACCESS`
Local dev only: `true` bypasses login. Never use in production.
### Storage backends (optional)
Admins usually add backends in **Storage** UI; env vars such as `STORAGE_BACKEND=s3` and `S3_*` are also supported. See [Storage](./storage.md).
## Docker Compose example
```yaml
services:
pichost:
image: muxui/pichost:latest
ports:
- "6892:6892"
volumes:
- ./data:/data
environment:
SITE_BASE_URL: https://admin.example.com
IMAGE_BASE_URL: https://pic.example.com
API_UPLOAD_TOKEN: your-secret-token
WEBP_QUALITY: "85"
```
Restart the container after changing env vars.
## Settings mapping
| In-app setting | Environment variable |
| -------------- | ---------------------- |
| API token | `API_UPLOAD_TOKEN` |
| Referer protection | `ALLOWED_REFERER_HOSTS` |
| Site URL | `SITE_BASE_URL` |
| Image URL | `IMAGE_BASE_URL` |
| Hide folder prefix | `HIDE_FOLDER_IN_URL` |
| Date-based paths | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
| WebP quality | `WEBP_QUALITY` |
| Auto-delete | `AUTO_DELETE_DAYS` |
Template: [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example) in the repo root.
+167
View File
@@ -0,0 +1,167 @@
# Dual-domain separation · Recommended deployment
When admin and image domains are split, the admin UI, API, and public image links use different hostnames. PicHost enforces isolation in **application middleware**: the image host only serves image paths; other requests return 404.
## Architecture
**One Docker instance + two domains**, both **fully reverse-proxied** to port `6892` (isolation is handled by PicHost middleware; no path splitting in Nginx).
| Domain | Purpose |
| ------ | ------- |
| `admin.example.com` | Admin UI, API, Twikoo upload |
| `pic.example.com` | Public image links (non-image paths blocked) |
Set during `/setup` or in **Settings**:
- **Site URL**: `https://admin.example.com`
- **Image URL**: `https://pic.example.com`
## Nginx example
```nginx
# admin.example.com — admin + API
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — same full proxy; PicHost middleware blocks non-image paths
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Default server: reject bare IP and unknown Host values (strongly recommended in production)
server {
listen 443 ssl default_server;
listen 80 default_server;
server_name _;
return 444;
}
```
> Caddy, NPM, and similar tools work the same: proxy both hostnames to `http://<host>:6892` and forward `Host` and `X-Forwarded-Proto`.
## Security notes
### Middleware only splits the two configured hostnames
PicHost uses the request **Host** (and `X-Forwarded-Host` when proxied) to decide site vs image domain:
- **Image host**: non-image paths (e.g. `/`, `/settings`) → 404
- **Site host**: image URL paths (e.g. `/images/...`) → 404
If the Host is **neither** the configured site nor image hostname (e.g. `localhost`, the server **public IP**, Cloudflare **Pages** `*.pages.dev`, **Workers** `*.workers.dev`, or any other unlisted name), **the app does not block the request today** — admin and API may still be reachable. This is not about hyphenated or long domain names; it is an extra entry point outside your dual-domain pair.
**Production recommendations:**
1. Configure `server_name` only for the site and image hosts; add a **default server** to reject other Host values and bare IP access (see Nginx example above)
2. With Cloudflare **orange-cloud** DNS, restrict the origin firewall to **[Cloudflare IP ranges](https://www.cloudflare.com/ips/)** so traffic cannot bypass the CDN
3. Sign in only on the **site hostname** from Settings; do not mix `localhost`, IP, or unlisted domains
4. Do **not** put Cloudflare Pages / Workers in front of PicHost as a **full-site reverse proxy** (extra `pages.dev` / `workers.dev` entry points worsen this issue)
5. PicHost **cannot** run as-is on Pages/Workers (needs `node-server`, SQLite, `sharp`, local `data/`). Keep Docker/VPS; use orange-cloud DNS and optional R2 storage on CF
### Cloudflare orange cloud (incl. preferred edge IPs)
**Recommended:** both `admin.example.com` and `pic.example.com` **DNS Proxied** → origin Nginx / 1Panel → port `6892`, SSL **Full (strict)** (origin cert or Cloudflare Origin Certificate).
| Feature | Notes |
| ------- | ----- |
| Orange-cloud CDN | Compatible with dual-domain; preserve `Host` and `X-Forwarded-Proto` on origin |
| DNS “preferred IP” setups | Only changes which CF edge IP clients hit; Host stays your real domain |
| R2 storage | Add an R2 backend under **Storage**; independent of orange cloud |
| `cloudflare` Git branch | R2-focused deployment line; still Docker, **not** Workers hosting |
**Do not confuse** “Create Worker” in the CF dashboard (Git + `wrangler deploy`) with deploying PicHost — the repo uses `nitro.preset: 'node-server'` and is not Workers-ready.
See [Reverse proxy](./reverse-proxy.md#cloudflare-orange-cloud) for origin hardening.
## FN NAS · Lucky reverse proxy
In [Lucky](https://github.com/gdy666/lucky), create one **reverse proxy** rule per domain. **Frontend** is the public hostname; **backend** is PicHost on your LAN (e.g. `http://192.168.8.3:6892`).
**Site domain (admin)**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `admin.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
**Image domain**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `image.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
Both rules can point to the same backend; PicHost applies host-based isolation automatically.
## Environment variables (optional)
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
See [Environment variables](./configuration.md).
## Local testing (Windows / macOS)
### 1. Edit hosts
```text
127.0.0.1 admin.pichost.test
127.0.0.1 pic.pichost.test
```
Prefer the `.test` TLD; avoid `.local` where it conflicts with mDNS.
### 2. Start dev server
```bash
npm run dev
```
Dev binds `127.0.0.1:3000` by default. If the hostname resolves but the browser cannot connect, check whether dev is listening on IPv6 `::1` only.
### 3. Configure dual domains
| Field | Example |
| ----- | ------- |
| Site URL | `http://admin.pichost.test:3000` |
| Image URL | `http://pic.pichost.test:3000` |
Always open admin on the **site hostname**, not `localhost` (cookies and Host must match).
### 4. Expected behavior
| URL | Expected |
| --- | -------- |
| `http://admin.pichost.test:3000/` | Admin UI works |
| `http://admin.pichost.test:3000/images/...` or `/2026/08/xxx.webp` | **404** (use image host) |
| `http://pic.pichost.test:3000/` | **404** (image host has no admin) |
| `http://pic.pichost.test:3000/images/...` | Image served |
| `http://localhost:3000/` or `http://127.0.0.1:3000/` | **Admin still loads** (third Host — isolation skipped; OK for dev only; block at proxy in production) |
After upload, copied links should use `pic.pichost.test`; gallery thumbnails should load.
## Notes
- Hostnames must **differ** (same machine, different subdomains is fine)
- Site / image URLs in Settings must **exactly match** proxy `server_name` (including `www` or not)
- No separate static root on the image domain; unlike EasyImages, PicHost isolates in-app
- Single-domain setups can skip separation and use `IMAGE_BASE_URL` or the request host
- When Referer protection is enabled, site and image hosts are whitelisted automatically
For single-domain proxy basics see [Reverse proxy](./reverse-proxy.md). Chinese version: [双域名分离](/guide/domain-separation)
+64
View File
@@ -0,0 +1,64 @@
# FAQ
## Forgot the admin password?
With `docker exec` access:
```bash
docker exec pichost reset-password
docker exec pichost reset-password <username>
```
Local: `npm run reset-password`. See [Quick start](./getting-started.md#forgot-password).
## Why can’t I regenerate the API token in the UI?
`API_UPLOAD_TOKEN` in the environment **locks** the token. Change env and **restart**. See [Environment variables](./configuration.md#api-upload-token).
## Hotlink / Referer errors?
Check the **Referer whitelist** (`ALLOWED_REFERER_HOSTS` or Settings). Referring sites must be listed; PicHost’s own hosts and dual-domain pair are allowed automatically.
## Why can admin load on pages.dev / workers.dev / IP with dual-domain?
Dual-domain isolation only splits paths on the **configured site and image hostnames**. Other Host values (`localhost`, public IP, Cloudflare **Pages** `*.pages.dev`, **Workers** `*.workers.dev`, etc.) are **not blocked today**, so admin may appear on unexpected URLs.
Common cause: **full-site reverse proxy** via Pages/Workers on top of orange-cloud DNS, or an origin that accepts any Host / bare IP.
Fix: use only the two configured domains; add a default `server_name` block; with orange cloud, allow only CF IPs on the origin. See [Security notes](./domain-separation.md#security-notes).
## Can I deploy PicHost on Cloudflare Workers / Pages?
**Not with the current codebase.** PicHost needs `node-server`, SQLite, `sharp`, and local `data/` — use Docker/VPS. Connecting the repo in “Create Worker” will not deploy successfully.
Recommended: run PicHost on origin; use **proxied DNS** (and optional **R2** storage). Do not full-proxy the app through Pages/Workers.
## Dual-domain: admin works but thumbnails break?
- Verify **IMAGE_BASE_URL** and proxy to the same instance
- Always open admin on the **site hostname**, not mixed with `localhost`
- See [Dual-domain separation](./domain-separation.md)
## Undeletable gallery items after v1.2 upgrade?
Likely **orphan index** rows (no file on disk). Startup sync removes them on restart. See [v1.2 migration](./migration.md#step-2-startup-index-sync-automatic).
## Do I still need `data/blog/` after migration?
After `migrate --apply`, files live under `data/images/blog/`. Remove leftover empty dirs after backup if you like; dirs with non-image files are kept by the CLI.
## Object storage only — run migrate?
No. Skip the CLI; startup sync focuses on local `data/images/` scanning.
## Replace old image URLs in posts?
Use `data/mapping.json` from `migrate` preview. Keys are **storage paths**, not full legacy URLs. See [Migration](./migration.md#urls).
## Skip login during development?
`DEV_BYPASS_ACCESS=true` (local only). Use `/setup` for normal flows.
## Docs vs README?
This **VitePress site** is the full guide; README is a short overview. Online: <https://o96u.github.io/PicHost/>
+72
View File
@@ -0,0 +1,72 @@
# Quick start
PicHost is a self-hosted image host for NAS, home servers, or VPS. Docker is recommended; the first visit runs a web wizard to create the admin account.
## Try it online
| Branch | Notes |
| ------ | ----- |
| **main** | No public demo — see [screenshots](./index.md#screenshots) or the repository README |
| **cloudflare** | R2-only live demo: [pic.roven.cc](https://pic.roven.cc) |
## Docker (recommended)
```bash
docker run -d \
--name pichost \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
```
Default port: **6892**. Open `http://<host>:6892` and complete setup.
If you cloned the repo:
```bash
docker compose up -d
```
See `docker-compose.yml` in the repository.
## First-time setup
1. Open your instance URL (Docker default `http://<host>:6892`).
2. Complete `/setup` with admin username and password.
3. (Optional) Configure site URL, image URL, Referer rules — see [Environment variables](./configuration.md) and [Dual-domain separation](./domain-separation.md).
4. Upload from the home page; admins can open **Storage**, **Settings**, and **API**.
## Sign in
Management pages (upload, stats, settings, etc.) require login. Enter username and password, **drag the slider to align with the gap**, then click **Sign in**.
![Login](/screenshots/login.png)
If registration is enabled, create an account from the register page. Legacy deployments may still use a secret key (see the on-screen hint).
## Forgot password
Requires `docker exec` (server access):
```bash
docker exec pichost reset-password # admin only when there is exactly one
docker exec pichost reset-password <username> # admin or regular user
```
A random password is printed. Change it after login.
Local dev: `npm run reset-password` or `npm run reset-password -- <username>`
## Upgrading to v1.2.0
If `data/` has legacy top-level folders next to `images` (e.g. `blog/`, `twikoo/`), read the [v1.2 migration guide](./migration.md) first.
Object-storage-only or files already under `data/images/` — pull the new image and restart.
## Next steps
- [Local development](./local-dev.md)
- [Environment variables](./configuration.md)
- [Storage](./storage.md)
- [API](./api.md)
+39
View File
@@ -0,0 +1,39 @@
# PicHost documentation
Welcome to the PicHost user guide. This site is the single source of truth; the repository README keeps a short overview and quick start.
## Suggested reading order
1. [Quick start](./getting-started.md) — Docker and first-time setup
2. [Environment variables](./configuration.md) — Production config vs in-app settings
3. [Storage](./storage.md) — Backends and URL modes
4. [API](./api.md) — REST endpoints and tokens
## Screenshots
**main** has no public demo. Below are v1.2.0 product screenshots.
| First-time setup | Login |
| :--------------: | :---: |
| ![Setup](/screenshots/setup.png) | ![Login](/screenshots/login.png) |
| API | Stats & gallery |
| :-: | :-------------: |
| ![API](/screenshots/api.png) | ![Stats & gallery](/screenshots/stats.png) |
| Storage | Settings |
| :-----: | :------: |
| ![Storage](/screenshots/storage.png) | ![Settings](/screenshots/settings-en.png) |
| Activity log |
| :----------: |
| ![Activity log](/screenshots/logs.png) |
## Branches
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) | Full product; screenshots above |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | R2-only; live demo [pic.roven.cc](https://pic.roven.cc) |
Use **main** for multiple cloud backends; use **cloudflare** if you only deploy with Cloudflare R2.
+54
View File
@@ -0,0 +1,54 @@
# Local development
Full-stack Nuxt 4 development at the repository root. Data defaults to `./data`.
## Requirements
- Node.js **22**
- npm **11** (see `packageManager` in `package.json`)
## Start
```bash
npm install
cp .env.example .env # optional
npm run dev
```
Open `http://localhost:3000/setup` to create the admin account. Data directory defaults to `./data` (`DATA_DIR` overrides).
## Common commands
| Command | Description |
| ------- | ----------- |
| `npm run dev` | Dev server |
| `npm run build` | Production build |
| `npm run start` | Run `.output` |
| `npm run lint` | ESLint |
| `npm run typecheck` | Type check |
| `npm test` | Unit tests |
| `npm run reset-password` | Reset password (optional username) |
| `npm run migrate` | Preview legacy dir migration (`-- --apply` to run) |
## Dev-only variables
| Variable | Description |
| -------- | ----------- |
| `DEV_BYPASS_ACCESS` | `true` bypasses login (skips `/setup`; rarely needed) |
| `DATA_DIR` | Data directory, default `./data` |
Full list: [Environment variables](./configuration.md).
## Dual-domain local test
Edit `hosts` and follow [Dual-domain separation](./domain-separation.md) for local verification.
## Documentation site
```bash
npm run docs:dev
npm run docs:build
npm run docs:preview
```
Sources live in `docs-site/`; screenshot sources are in `docs/screenshots/` (including `login.png`), copied to `docs-site/public/screenshots/` before build.
+129
View File
@@ -0,0 +1,129 @@
# v1.2.0 local disk migration guide
From v1.2.0, **all local image files** live under `data/images/`; SQLite `keys` also start with `images/`. Legacy top-level folders next to `images` (e.g. `data/blog/`, `data/twikoo/`) are no longer used.
Migration is **two steps**: run the **CLI** to move files on disk, then let **startup sync** align the SQLite index.
---
## Migrating from other image hosts (EasyImage, Lsky, Chevereto, …)
There is **no** dedicated “pull from URLs” or “import via vendor API” tool. Moving from **legacy PicHost** or **another self-hosted** install uses the same **local file** workflow:
1. Locate the **image files** on the old server (copy the tree, not just the database).
2. Place them under PicHost `data/` as a **top-level folder** next to `images` (any name — it becomes `images/{name}/...`).
3. Run `migrate` / `migrate --apply`, then restart PicHost.
| Source | Typical path on old server | After copy into PicHost |
| ------ | -------------------------- | ----------------------- |
| PicHost v1.1.x | `data/blog/`, `data/twikoo/` | Already under `data/` → run `migrate` |
| EasyImage(s) | `i/` or `upload/` | e.g. `data/easyimage/` → `migrate` |
| Lsky | `public/uploads/` etc. | e.g. `data/lsky/` → `migrate` |
| Chevereto etc. | `images/` or `content/` | e.g. `data/chevereto/` → `migrate` |
| Custom | your folder name | `data/your-folder/` → `migrate` |
The CLI scans **every top-level directory under `data/` except `images`**, not only `blog` / `twikoo`.
**Limits:** files only — accounts, tokens, and old public URLs are not imported. `mapping.json` maps old **storage keys**, not full old-site URLs. For S3/R2-only legacy hosts, sync blobs to `data/…` or attach the bucket in PicHost **Storage** settings.
---
## Layout before and after
### Before (typical v1.1.x)
```
/data
├── pichost.db
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
├── twikoo/
└── images/
```
Index keys might look like `blog/2026/08/TeKJ2fD6cB6j.webp`.
### After (v1.2.0)
```
/data
├── pichost.db
├── mapping.json
└── images/
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
└── twikoo/
```
Index key: `images/blog/2026/08/TeKJ2fD6cB6j.webp`.
---
## Step 1: CLI file migration
Run **before or after** upgrading the image; backup `data/` first.
```bash
docker exec pichost migrate # preview + mapping.json
docker exec pichost migrate --apply # move files
```
Local: `npm run migrate` / `npm run migrate -- --apply`.
| Item | Behavior |
| ---- | -------- |
| Scan | Every top-level directory under `data/` **except `images`** |
| Target | `data/images/{original-top-dir}/{relative-path}` |
| Extensions | jpg, jpeg, png, webp, gif, svg, ico |
| Sidecar | `.meta.json` next to an image is moved if present |
| Other files | Not moved; parent directory kept if anything remains |
| Mapping | `data/mapping.json` — old key → new key |
Script: `server/cli/migrate-to-single-images.mjs`.
---
## Step 2: Startup index sync (automatic)
Runs on every start (`npm run dev`, Docker). No manual command.
```text
[INFO] 开始同步图片索引:扫描本地磁盘 ...
[INFO] 磁盘扫描完成:...
[INFO] 开始归一化遗留图片路径 ...
[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录
[INFO] 图片索引同步结束
```
| Step | Purpose |
| ---- | ------- |
| Scan `data/images/` | Insert missing index rows for files on disk |
| Normalize legacy keys | `blog/...` → `images/blog/...` when files are in place |
| Purge orphans | Remove index rows with no file on disk |
| Repair Content-Type | Fix `application/octet-stream` entries |
The CLI only **moves files**. Index alignment runs at startup — **restart** after `migrate --apply`.
---
## Recommended upgrade flow
1. Backup `data/`.
2. `docker exec pichost migrate` — review output and `mapping.json`.
3. `docker exec pichost migrate --apply`.
4. Deploy v1.2.0 and restart.
5. Check logs for successful index sync.
6. Use `mapping.json` to update external URLs if needed.
Skip step 1 if you only use object storage or everything is already under `data/images/`.
---
## URLs
| Old key | `blog/TeKJ2fD6cB6j.webp` |
| New key | `images/blog/TeKJ2fD6cB6j.webp` |
With “hide folder” mode, short URLs like `/TeKJ2fD6cB6j.webp` may still resolve by filename. Use `mapping.json` for bulk replacements.
Chinese version: [v1.2 迁移指南](/guide/migration)
+83
View File
@@ -0,0 +1,83 @@
# Reverse proxy
In production, terminate HTTPS with Nginx, Caddy, NPM (Nginx Proxy Manager), or your NAS proxy and forward to PicHost on port **6892**.
## Basics
- Upstream: `http://127.0.0.1:6892` (or host IP)
- Forward: `Host`, `X-Forwarded-Proto` (and `X-Forwarded-For` as needed)
- Body size: Nginx `client_max_body_size 12m` or larger
- Use **HTTPS** on the public edge
## Single domain (Nginx)
Admin, API, and images on one hostname:
```nginx
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
```
Optional:
```env
IMAGE_BASE_URL=https://pic.example.com
```
Without `IMAGE_BASE_URL`, PicHost infers the public URL from forwarded headers.
## Dual domains
Both hostnames can fully proxy to the same port; isolation is enforced in PicHost middleware.
See [Dual-domain separation](./domain-separation.md).
## Cloudflare orange cloud
Run PicHost on **your server / Docker** (port `6892`). On Cloudflare:
| Practice | Notes |
| -------- | ----- |
| **Proxied DNS** | Both admin and image hostnames → origin proxy to `6892`, SSL **Full (strict)** |
| Preferred edge IPs | Compatible; Host remains your real domain |
| **R2** | Object storage backend in **Storage**; not the same as hosting the app on Pages |
| Origin firewall | Allow only [Cloudflare IPs](https://www.cloudflare.com/ips/); do not expose `6892` publicly |
**Avoid:**
- **Cloudflare Pages** (`*.pages.dev`) or **Workers** (`*.workers.dev`) as a **full-site reverse proxy** to PicHost — extra hostnames can bypass dual-domain isolation ([Security notes](./domain-separation.md#security-notes))
- “Create Worker” from Git on this repo — `node-server` preset is not Workers-ready
## Origin hardening (dual-domain)
PicHost middleware does **not** reject unlisted Host values. Complement with:
1. **`server_name` allowlist** — site + image hosts only; `default_server` blocks bare IP and unknown Host
2. **Do not expose 6892 on the public Internet** — only local reverse proxy; public 80/443 only
3. **Orange cloud** — origin firewall limited to Cloudflare IP ranges
## Caddy
```caddy
pic.example.com {
reverse_proxy 127.0.0.1:6892
}
```
Add a second site block for the second hostname with the same upstream.
## Caching
Image 404/403 and domain-isolation 404 responses are **no-cache** to avoid sticky CDN errors. Be careful caching API paths behind a CDN.
## See also
- [Environment variables](./configuration.md)
- [Quick start](./getting-started.md)
+66
View File
@@ -0,0 +1,66 @@
# Storage
PicHost supports **local disk** and **S3-compatible object storage** (Cloudflare R2, Tencent COS, Alibaba OSS, AWS S3, etc.). Admins manage backends at **Storage** (`/storage`); new uploads go to the **default** backend.
![Storage management](/screenshots/storage.png)
## Layout (local)
```
/data
├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
└── images/ # All files when using local backend
```
- New uploads: `images/randomId.webp` or `images/YYYY/MM/randomId.webp` (flat mode in Settings)
- Migrated legacy: `images/blog/...` and similar
- With cloud backends, blobs live in the bucket; index stays in SQLite `images`
All **keys** start with `images/`. The upload API does **not** accept a `folder` parameter.
## Adding a backend
1. Sign in as admin → **Storage** → **Add backend**.
2. Choose **local disk** or **S3-compatible** (R2 / COS / OSS presets).
3. Enter connection details (bucket, endpoint, keys).
4. **Set as default** so new uploads use it.
### Cloudflare R2
| Field | Value |
| ----- | ----- |
| Endpoint | `https://<account_id>.r2.cloudflarestorage.com` |
| Region | `auto` |
| Bucket | Your R2 bucket name |
| Keys | R2 API token access / secret |
The **cloudflare** branch streamlines R2-only deploys; see branch notes on the [docs overview](./index.md).
### Environment variables (optional)
Some installs seed a default S3 backend via `STORAGE_BACKEND=s3`, `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`. **UI-configured backends live in SQLite** alongside env-based defaults.
## URL modes
| Mode | Behavior |
| ---- | -------- |
| **proxy** | Serve via PicHost (`GET /images/...`) |
| **public** | 302 redirect to bucket/CDN public URL |
Copied links use **IMAGE_BASE_URL**. “Hide folder prefix” may shorten URLs; the server resolves by path or basename.
## Usage & gallery filter
- Per-backend usage on the Storage page
- Gallery filter by backend (admin)
## Backup
Back up `data/images/`, `data/pichost.db`, and bucket objects.
Upgrading from v1.1.x with parallel folders: [v1.2 migration](./migration.md).
## See also
- [Environment variables](./configuration.md)
- [Users & permissions](./users-and-permissions.md)
+49
View File
@@ -0,0 +1,49 @@
# Twikoo
PicHost supports **Twikoo** and **EasyImage 2.0** image upload — configure your comment system like EasyImage.
## Twikoo settings
| Setting | Value |
| ------- | ----- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://your-domain/api/index.php` |
| `IMAGE_CDN_TOKEN` | Same as global `API_UPLOAD_TOKEN` |
- Use your **site hostname** (admin domain) in `IMAGE_CDN_URL`
- Generate the token in PicHost **API** or set `API_UPLOAD_TOKEN`
## Upload endpoint
`POST /api/index.php`
| Field | Description |
| ----- | ----------- |
| `image` | Image file |
| `token` | Global API token (same as `Auth-Token`) |
```bash
curl -X POST "https://admin.example.com/api/index.php" \
-F "token=YOUR_TOKEN" \
-F "image=@./demo.png"
```
Files are stored under `images/` and owned by the **global token** account (admin).
## REST vs Twikoo
| Aspect | REST `/api/images/upload` | Twikoo `/api/index.php` |
| ------ | ------------------------- | ------------------------ |
| Auth | `Auth-Token` header | form `token` |
| Response | PicHost JSON | EasyImage-compatible JSON |
| Token | Global or personal | Global only |
## Dual-domain
Point Twikoo uploads at **SITE_BASE_URL**; public images use **IMAGE_BASE_URL**. See [Dual-domain separation](./domain-separation.md).
## See also
- [API](./api.md)
- [Users & permissions](./users-and-permissions.md)
- [Environment variables](./configuration.md)
@@ -0,0 +1,58 @@
# Users & permissions
PicHost uses role-based access control (RBAC): **admin** and **user**. Enforcement is server-side.
## Role capabilities
| Capability | Admin | User |
| ---------- | :---: | :--: |
| Upload images | ✓ | ✓ |
| Own gallery | ✓ | ✓ |
| All galleries / uploader badges | ✓ | — |
| Storage management (`/storage`) | ✓ | — |
| System settings (`/settings`) | ✓ | — |
| Allow registration | ✓ | — |
| Global API token | ✓ | — |
| Personal API token | ✓ | ✓ |
| Stats (incl. user count) | ✓ | partial |
| Activity log | ✓ | — |
Regular users see **API**, **Stats** in the nav; user menu: **Change password**, **Sign out**.
## Auth scenarios
| Scenario | Auth | Ownership / visibility |
| -------- | ---- | ---------------------- |
| Web upload | Session cookie | Current user |
| API upload + **user token** | `Auth-Token` header | Token owner |
| API upload + **global token** | `Auth-Token` header | Admin (`userId` null) |
| Twikoo `POST /api/index.php` | form `token` | Same as global token |
| Gallery list / search / delete | Session or token | Users: own only; admin: all |
| Direct link `GET /images/...` | None (Referer rules) | Public if URL is known |
## Accounts
- By default only admins create users; **allow registration** is an admin setting
- Passwords hashed with scrypt
- Web login requires a slider captcha (align the thumb with the gap)
- Login rate limiting
## Upload preferences & auto-delete
- **Upload preferences** (home card flip): client compression, auto-copy links, per user
- **Auto-delete**: global (admin) and per-user policies; affects **new uploads after enable** only
## API tokens
| Type | Managed by | Upload owner | Typical use |
| ---- | ---------- | ------------ | ----------- |
| **Global** | Admin (API page) | Admin account | Twikoo, site-wide scripts |
| **Personal** | Each user (API page) | That user | Personal blog, private scripts |
`API_UPLOAD_TOKEN` in env locks the global token — see [Environment variables](./configuration.md).
## See also
- [API](./api.md)
- [Twikoo](./twikoo.md)
- [Storage](./storage.md)
+32
View File
@@ -0,0 +1,32 @@
---
layout: home
hero:
name: PicHost
text: Lightweight image hosting
tagline: Self-hosted · Multi-user · Docker · API · Twikoo · Local disk or object storage
actions:
- theme: brand
text: Quick start
link: /en/guide/getting-started
- theme: alt
text: API reference
link: /en/guide/api
- theme: alt
text: GitHub
link: https://github.com/O96u/PicHost
features:
- title: Drag, click, paste
details: Server-side WebP compression and Referer hotlink protection.
- title: Multi-backend storage
details: Local disk plus S3-compatible backends (R2 / COS / OSS / AWS); proxy or public URLs.
- title: Users & RBAC
details: Admin and regular users; global and per-user API tokens; gallery scoped by owner.
- title: Zero-config Docker
details: First-run web wizard; reset passwords with `docker exec pichost reset-password`.
- title: Dual-domain separation
details: One instance, two hostnames, full reverse proxy; middleware enforces isolation.
- title: Twikoo compatible
details: "POST /api/index.php follows EasyImage 2.0 for comment systems."
---
+99
View File
@@ -0,0 +1,99 @@
# API
PicHost 提供 REST 接口与 Twikoo 兼容端点。部署后登录 **API** 页可查看当前 Token 与可复制 cURL 示例。
## 鉴权
所有 REST 接口使用请求头:
```http
Auth-Token: YOUR_TOKEN
```
| Token 类型 | 获取方式 | 说明 |
| ---------- | -------- | ---- |
| **全局 Token** | 管理员 **API** 页生成;或 `API_UPLOAD_TOKEN` 环境变量 | 环境变量优先且锁定后台重新生成 |
| **个人 Token** | 各用户 **API** 页 | 仅用于该用户脚本,上传归属本人 |
表单上传仍可使用字段 `token`(Twikoo 协议)。
## 接口列表
### 1. 上传图片
`POST /api/images/upload`
上传单张或多张图片。字段 `image` 传图片;仍兼容 `file`、`files`。存入 `images/`。
```bash
curl -X POST "https://admin.example.com/api/images/upload" \
-H "Auth-Token: YOUR_TOKEN" \
-F "image=@./demo.png"
```
### 2. 获取图片列表
`GET /api/images`
分页列出图库。`limit` 默认 20、最大 100。
```bash
curl "https://admin.example.com/api/images?limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 3. 搜索图片
`GET /api/images/search`
按文件名或路径关键词搜索,参数 `q` 必填。
```bash
curl "https://admin.example.com/api/images/search?q=demo&limit=20&page=1" \
-H "Auth-Token: YOUR_TOKEN"
```
### 4. 删除图片
`DELETE /api/images`
通过 `key` 删除单张,`key` 为存储路径(如 `images/2026/08/xxx.webp`)。
```bash
curl -X DELETE "https://admin.example.com/api/images?key=images/2026/08/xxxx.webp" \
-H "Auth-Token: YOUR_TOKEN"
```
### 5. 批量删除
`POST /api/images/batch-delete`
请求体 JSON:`{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}`
```bash
curl -X POST "https://admin.example.com/api/images/batch-delete" \
-H "Auth-Token: YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"keys":["images/2026/08/a.webp","images/2026/08/b.webp"]}'
```
## 错误格式
REST 接口返回 JSON,含 `code` 与 `message`(如 `UNAUTHORIZED`、`FORBIDDEN`、`INVALID_REQUEST`)。Twikoo 端点使用 EasyImage 兼容 JSON 格式。
## 权限与可见范围
- 普通用户 Token:列表/搜索/删除仅作用于自己的图片
- 管理员 Token:可访问全部图片
- 全局 Token 上传的图片归属管理员
详见 [用户与权限](./users-and-permissions.md)。
## Twikoo
`POST /api/index.php` 见 [Twikoo](./twikoo.md)。
## 相关
- [环境变量](./configuration.md) — `API_UPLOAD_TOKEN`
- [反向代理](./reverse-proxy.md) — 公网 URL 与 HTTPS
+101
View File
@@ -0,0 +1,101 @@
# 环境变量
PicHost 支持通过环境变量(Docker / `.env`)与后台 **设置** 页配置。多数项 **SQLite 优先**;部分项环境变量可覆盖或锁定后台编辑。
## 优先级总览
| 配置项 | 优先级(高 → 低) | 环境变量覆盖后台 |
| ------ | ----------------- | ---------------- |
| API 上传 Token | 环境变量 → SQLite | **是**(设了则后台无法重新生成) |
| WebP 质量 | SQLite → 环境变量 → 默认 80 | 否(未在 DB 存时读 env) |
| 防盗链白名单 | SQLite → 环境变量 | 否 |
| 网站 / 图片域名 | SQLite → 环境变量 → 当前请求 | 否 |
| 隐藏 `images/` 前缀 | SQLite → 环境变量 → 默认 false | 否 |
| 按年/月分组存储 | SQLite → 环境变量 → 默认 true | 否 |
| 全局自动删除天数 | SQLite → 环境变量 → 默认 0 | 否 |
## 变量说明
### `API_UPLOAD_TOKEN`
外部脚本上传 Token(Twikoo / 油猴 / 博客)。请求头:`Auth-Token: <token>`;表单上传仍可用 `token` 字段。
- 未配置时可在后台 **API** 页生成
- **环境变量一旦设置,优先级最高**,后台显示「由环境变量覆盖」且无法重新生成
- 别名:`NUXT_API_UPLOAD_TOKEN`
### `ALLOWED_REFERER_HOSTS`
防盗链白名单,逗号分隔域名。自身域名自动放行。可选;不填可在设置页配置。若环境变量有值且 SQLite 未存,则使用环境变量。
### `SITE_BASE_URL` / `IMAGE_BASE_URL`
- **网站域名**:管理后台、API、Twikoo 上传入口
- **图片域名**:复制链接与直链使用的公网地址
双域名分离需两者主机名不同,详见 [双域名分离](./domain-separation.md)。填写后须与反代 `server_name` 一致;**勿**通过 `localhost`、IP 或未配置的域名访问生产后台。
### `HIDE_FOLDER_IN_URL`
`true` 时直链隐藏 `images/` 前缀(如 `images/2026/08/a.webp` → `/2026/08/a.webp` 或仅文件名,取决于路径模式)。
### `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT`
- `STORAGE_USE_DATE_PATH=true`(默认)或 `STORAGE_LAYOUT=date`:新图保存为 `images/年/月/随机ID.webp`
- `false` 或 `STORAGE_LAYOUT=flat`:保存为 `images/随机ID.webp`
也可在设置页切换。
### `DATA_DIR`
图片与 SQLite 根目录。默认 `./data`,容器内为 `/data`。
### `WEBP_QUALITY`
服务端 WebP 压缩质量 1–100,默认 **80**。
### `AUTO_DELETE_DAYS`
全局自动删除超过 N 天的图片,`0` 表示关闭。启用后仅影响**之后新上传**的图片(与按用户策略一致)。也可在设置页配置。
### `DEV_BYPASS_ACCESS`
仅本地开发:`true` 绕过登录验证。生产环境勿用。
### 存储后端(可选)
管理员通常在 **存储** 页添加后端;也可用环境变量引导(如 `STORAGE_BACKEND=s3` 与 `S3_*`)。详见 [存储](./storage.md)。
## Docker Compose 示例
```yaml
services:
pichost:
image: muxui/pichost:latest
ports:
- "6892:6892"
volumes:
- ./data:/data
environment:
SITE_BASE_URL: https://admin.example.com
IMAGE_BASE_URL: https://pic.example.com
API_UPLOAD_TOKEN: your-secret-token
WEBP_QUALITY: "85"
```
修改环境变量后需 **重启容器** 才能生效(Token、质量等读取在启动时或请求时按实现读取)。
## 与后台设置对照
| 后台设置项 | 对应环境变量 |
| ---------- | ------------ |
| API Token | `API_UPLOAD_TOKEN` |
| 防盗链 | `ALLOWED_REFERER_HOSTS` |
| 网站域名 | `SITE_BASE_URL` |
| 图片域名 | `IMAGE_BASE_URL` |
| 隐藏目录前缀 | `HIDE_FOLDER_IN_URL` |
| 按年/月分组 | `STORAGE_USE_DATE_PATH` / `STORAGE_LAYOUT` |
| WebP 质量 | `WEBP_QUALITY` |
| 自动删除 | `AUTO_DELETE_DAYS` |
完整模板见仓库根目录 [`.env.example`](https://github.com/O96u/PicHost/blob/main/.env.example)。
+172
View File
@@ -0,0 +1,172 @@
# 双域名分离 · 推荐部署架构
启用「后台与图片域名分离」后,管理后台、API 与图片直链使用不同域名。PicHost 通过 **Host 中间件** 双向隔离:
- **网站域**:后台、API、上传;**禁止**直链出图(图片路径返回 404)
- **图片域**:仅放行图片路径;其余请求返回 404
## 架构说明
**单 Docker 实例 + 两个域名**,均可 **全量反代** 到 `6892`(隔离由 PicHost 中间件负责,无需在 Nginx 层拆分路径)。
| 域名 | 用途 |
| ---- | ---- |
| `admin.example.com` | 管理后台、API、Twikoo 上传 |
| `pic.example.com` | 图片直链(中间件拦截非图片路径) |
初始化时在 `/setup` 或后台 **设置** 中填写:
- **网站域名**:`https://admin.example.com`
- **图片域名**:`https://pic.example.com`
## Nginx 示例
```nginx
# admin.example.com — 后台 + API(不出图,直链请走图片域)
server {
server_name admin.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
# pic.example.com — 同样全量反代;PicHost 中间件会拦截非图片路径
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# 默认 server:拒绝裸 IP、未配置域名等其它 Host(生产环境强烈建议)
server {
listen 443 ssl default_server;
listen 80 default_server;
server_name _;
return 444;
}
```
> Caddy、NPM(Nginx Proxy Manager)等工具同理:两个域名均反代到 `http://<主机>:6892`,并保留 `Host` 与 `X-Forwarded-Proto` 头。
## 安全与注意事项
### 中间件只隔离「已配置的两个域名」
PicHost 根据请求 **Host**(反代会转发为 `X-Forwarded-Host`,应用会一并识别)判断当前是网站域还是图片域:
- **图片域**:非图片路径(如 `/`、`/settings`)→ 404
- **网站域**:图片直链路径(如 `/images/...`)→ 404
若 Host **既不是网站域也不是图片域**(例如 `localhost`、服务器 **公网 IP**、Cloudflare **Pages** 的 `*.pages.dev`、**Workers** 的 `*.workers.dev`、或其它未在设置里填写的域名),**当前版本不会拦截**,后台与 API 仍可能被访问。这与域名是否带 `-`、长短无关,而是多了一条未纳入双域名配置的入口。
**生产环境建议:**
1. 反代层仅为网站域、图片域配置 `server_name`,并增加 **default server** 拒绝其它 Host 与裸 IP(见上方 Nginx 示例)
2. 使用 Cloudflare **橙云** 时,源站防火墙 **仅放行 [Cloudflare IP 段](https://www.cloudflare.com/ips/)**,避免绕过 CDN 直连源站
3. **仅使用** 设置中的网站域名登录后台,不要用 `localhost`、IP 或未配置的域名混用
4. **不要** 用 Cloudflare Pages / Workers 对 PicHost **整站反代**(会多出 `pages.dev` / `workers.dev` 等第三入口,且易出现上述绕过)
5. PicHost **不能** 作为 Node 应用直接部署到 Pages/Workers(依赖 `node-server`、SQLite、`sharp` 与本地 `data/`);应用请继续 Docker/VPS 部署,CF 侧用橙云 DNS + 可选 R2 存储即可
### Cloudflare 橙云(含 DNS 优选)
**推荐架构:** `admin.example.com` 与 `pic.example.com` 均 **DNS Proxied(橙云)** → 源站 Nginx / 1Panel 反代 `6892`,SSL 模式 **Full (strict)**(源站可用 Cloudflare Origin Certificate)。
| 能力 | 说明 |
| ---- | ---- |
| 橙云 CDN | 与 PicHost 双域名兼容;回源须保留 `Host`、`X-Forwarded-Proto` |
| DNS 优选 | 仅改变用户连到 CF 边缘的 IP,Host 仍为正式域名,无额外配置 |
| R2 对象存储 | 在 **存储** 页添加 R2 后端即可,与是否橙云无关 |
| `cloudflare` Git 分支 | R2 专用部署线,仍是 Docker 运行,**不是** Workers 托管 |
**勿混淆:** CF 控制台「创建 Worker」并连接本仓库 **无法** 直接部署 PicHost;`npm run build` + `wrangler deploy` 与当前 `node-server` 预设不兼容。
反代与源站加固详见 [反向代理](./reverse-proxy.md#cloudflare-橙云)。
## 飞牛 NAS · Lucky 反向代理
在 [Lucky](https://github.com/gdy666/lucky) 中为两个域名各建一条 **反向代理** 规则,**前端地址** 填公网域名,**后端地址** 填 PicHost 所在内网 IP 与端口(如 `http://192.168.8.3:6892`)。
**网站域名(管理后台)**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `admin.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
**图片域名**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `image.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
两条规则的后端地址相同即可;域名分离与路径隔离由 PicHost 根据请求 `Host` 自动处理。
## 环境变量(可选)
除 Web 设置外,也可在 `.env` 或 Docker 环境中配置:
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
详见 [环境变量](./configuration.md)。
## 本地测试(Windows / macOS)
### 1. 修改 hosts
```text
127.0.0.1 admin.pichost.test
127.0.0.1 pic.pichost.test
```
建议使用 `.test` 后缀,避免 `.local` 在部分系统上与 mDNS 冲突。
### 2. 启动开发服务
```bash
npm run dev
```
项目已默认绑定 `127.0.0.1:3000`(与 hosts 的 IPv4 一致)。若曾出现 `ping` 通但浏览器连不上,多半是 dev 只监听了 IPv6 `::1`。
### 3. 填写双域名
| 项 | 示例 |
| -- | ---- |
| 网站域名 | `http://admin.pichost.test:3000` |
| 图片域名 | `http://pic.pichost.test:3000` |
**后台请始终用网站域名打开**,不要用 `localhost`(Cookie 与 Host 不一致)。
### 4. 预期结果
| 地址 | 预期 |
| ---- | ---- |
| `http://admin.pichost.test:3000/` | 正常进入后台 |
| `http://admin.pichost.test:3000/images/...` 或 `/2026/08/xxx.webp` | **404**(请用图片域直链) |
| `http://pic.pichost.test:3000/` | **404**(正常,图片域不提供后台) |
| `http://pic.pichost.test:3000/images/...` | 能出图 |
| `http://localhost:3000/` 或 `http://127.0.0.1:3000/` | **能打开后台**(第三 Host,隔离不生效;仅开发可接受,生产须用反代封禁) |
上传一张图后,复制链接应指向 `pic.pichost.test` 域名;在后台图库中缩略图也应能正常显示。
## 相关说明
- 两个域名 **主机名不能相同**(可同机、不同子域)
- 设置里的网站域 / 图片域须与反代 `server_name` **完全一致**(含是否带 `www`)
- 图片域无需单独部署静态目录;与 EasyImages 等不同,PicHost 在应用内完成隔离
- 单域名部署可不启用分离,仅配置 `IMAGE_BASE_URL` 或使用默认当前访问域名
- 配置了 Referer 防盗链时,网站域与图片域会自动加入白名单,无需手写两个域名
单域名反代要点见 [反向代理](./reverse-proxy.md)。英文版:[Dual-domain separation](/en/guide/domain-separation)
+65
View File
@@ -0,0 +1,65 @@
# 常见问题
## 忘记管理员密码怎么办?
能执行 `docker exec` 时:
```bash
docker exec pichost reset-password
# 或指定用户名
docker exec pichost reset-password 用户名
```
本地:`npm run reset-password`。详见 [快速开始](./getting-started.md#忘记密码)。
## 为什么后台无法重新生成 API Token?
若设置了环境变量 `API_UPLOAD_TOKEN`,Token 由环境变量 **锁定**,需修改 env 并 **重启服务**。见 [环境变量](./configuration.md#api-upload-token)。
## 图片外链显示「盗链」或无法加载?
检查 **防盗链白名单**(`ALLOWED_REFERER_HOSTS` 或设置页)。引用图片的站点域名需在白名单中;PicHost 自身域名与双域名场景下的网站域、图片域会自动放行。
## 双域名下为什么用 pages.dev / workers.dev / IP 也能进后台?
PicHost 双域名隔离**只对已配置的网站域、图片域做路径分流**;其它 Host(`localhost`、公网 IP、Cloudflare Pages 的 `*.pages.dev`、Workers 的 `*.workers.dev` 等)**当前不会拦截**,因此可能出现「奇怪地址也能开后台」。
常见原因:在橙云之外又用 **Pages/Workers 整站反代** 到源站,或源站未限制 `server_name` / 允许裸 IP 访问。
处理:仅用正式两个域名访问;反代加 default server;橙云时源站只放行 CF IP。详见 [双域名分离 · 安全与注意事项](./domain-separation.md#安全与注意事项)。
## 能把 PicHost 部署到 Cloudflare Workers / Pages 吗?
**不能(现状)。** 项目使用 `node-server`、SQLite、`sharp` 与本地 `data/`,需 Docker / VPS。CF 控制台「从 Git 创建 Worker」连本仓库会部署失败。
推荐:源站跑 PicHost,CF 用 **橙云 DNS**(含优选)+ 可选 **R2 存储**;不要用 Pages/Workers 反代整站。
## 双域名下后台能打开但缩略图裂图?
- 确认 **图片域名** 配置正确且反代到同一实例
- 后台请用 **网站域名** 打开,不要用 `localhost` 混用
- 见 [双域名分离](./domain-separation.md)
## 升级 v1.2 后图库有删不掉的记录?
可能是 **孤儿索引**(有记录无文件)。重启后启动同步会自动清理;也可手动删除仅索引项。见 [v1.2 迁移](./migration.md#第二步启动时自动同步索引)。
## 遗留 `data/blog/` 目录还要吗?
执行 `migrate --apply` 后文件已迁入 `data/images/blog/`。若原目录只剩空壳或非图片文件,可自行备份后删除;迁移脚本不会删除仍含非图片文件的目录。
## 只用对象存储需要跑 migrate 吗?
不需要。图片在桶内时跳过 CLI;启动同步主要面向本地 `data/images/` 磁盘扫描。
## 如何替换博客里的旧图片 URL?
使用 `data/mapping.json`(`migrate` 预览时生成)批量替换旧 key。注意映射的是 **存储 key**,不是完整旧站 URL。见 [迁移指南](./migration.md#外链与直链)。
## 开发时如何跳过登录?
`DEV_BYPASS_ACCESS=true`(仅本地,勿用于生产)。正常流程请访问 `/setup` 创建管理员。
## 文档与仓库 README 哪个为准?
**本 VitePress 文档站** 为完整用户指南;README 保留摘要与快速开始。在线地址:<https://o96u.github.io/PicHost/>
+75
View File
@@ -0,0 +1,75 @@
# 快速开始
PicHost 适合在 NAS、家用服务器或 VPS 上自托管图床。推荐用 Docker 部署,首次访问通过 Web 向导创建管理员。
## 在线体验
| 分支 | 说明 |
| ---- | ---- |
| **main** | 无公网演示;产品界面见 [文档首页](./index.md#截图预览) 或仓库 README |
| **cloudflare** | R2 专用线在线演示:[pic.roven.cc](https://pic.roven.cc) |
## Docker(推荐)
```bash
docker run -d \
--name pichost \
-p 6892:6892 \
-v ./data:/data \
--restart unless-stopped \
muxui/pichost:latest
```
默认端口 **6892**。浏览器打开 `http://<主机IP>:6892`,按引导创建管理员即可。
已克隆仓库时也可用:
```bash
docker compose up -d
```
见仓库内 `docker-compose.yml`。
## 首次设置
1. 访问实例 URL(Docker 默认 `http://<主机>:6892`)。
2. 在 `/setup` 填写管理员用户名与密码。
3. (可选)配置网站域名、图片域名、防盗链等 — 详见 [环境变量](./configuration.md) 与 [双域名分离](./domain-separation.md)。
4. 登录后可在首页上传图片;管理员可进入 **存储**、**设置**、**API** 页。
## 登录
访问上传、统计、设置等管理页面前需登录。在登录卡片中填写用户名与密码,**拖动滑块对齐缺口**完成验证后点击 **登录**。
![登录页](/screenshots/login.png)
若管理员开启了开放注册,可前往注册页创建账号。遗留部署仍可能使用密钥登录(见界面提示)。
## 忘记密码
需能执行 `docker exec`(相当于服务器权限):
```bash
# 无参数:重置管理员(仅当系统中只有一个管理员时)
docker exec pichost reset-password
# 指定用户名:可重置管理员或普通用户
docker exec pichost reset-password 用户名
```
终端会打印随机新密码;用户不存在时会报错。登录后请到「修改密码」更换。
本地开发:`npm run reset-password` 或 `npm run reset-password -- 用户名`
## 升级到 v1.2.0
若 `data/` 下存在与 `images` 并列的遗留目录(如 `blog/`、`twikoo/`),升级前请阅读 [v1.2 迁移指南](./migration.md)。
仅对象存储、或图片已在 `data/images/` 下时,拉取新镜像并重启即可。
## 下一步
- [本地开发](./local-dev.md) — `npm run dev` 与 `.env`
- [环境变量](./configuration.md) — 生产配置
- [存储](./storage.md) — 多后端与直链
- [API](./api.md) — Token 与 REST 接口
+39
View File
@@ -0,0 +1,39 @@
# PicHost 文档
欢迎使用 PicHost 用户文档。本站为完整指南的单一事实来源;仓库 README 保留特性摘要与快速开始。
## 推荐阅读顺序
1. [快速开始](./getting-started.md) — Docker 部署与首次设置
2. [环境变量](./configuration.md) — 生产环境配置与后台设置对照
3. [存储](./storage.md) — 多后端与直链模式
4. [API](./api.md) — REST 接口与 Token
## 截图预览
**main** 分支暂无公网演示,以下为 v1.2.0 产品界面。
| 首次设置 | 登录 |
| :------: | :--: |
| ![首次设置](/screenshots/setup.png) | ![登录](/screenshots/login.png) |
| API | 统计与图库 |
| :-: | :--------: |
| ![API](/screenshots/api.png) | ![统计与图库](/screenshots/stats.png) |
| 存储管理 | 系统设置 |
| :------: | :------: |
| ![存储管理](/screenshots/storage.png) | ![系统设置](/screenshots/settings.png) |
| 操作日志 |
| :------: |
| ![操作日志](/screenshots/logs.png) |
## 分支说明
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main) | 主线:多后端、双域名、统一 `images/` 存储;界面见上表 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | 仅 Cloudflare R2;在线演示 [pic.roven.cc](https://pic.roven.cc) |
需要多种云存储或完整管理界面请用 **main**;图床只接 R2 可选用 **cloudflare** 分支。
+54
View File
@@ -0,0 +1,54 @@
# 本地开发
在仓库根目录进行 Nuxt 4 全栈开发,数据默认写入 `./data`。
## 环境要求
- Node.js **22**
- npm **11**(与 `package.json` 中 `packageManager` 一致)
## 启动
```bash
npm install
cp .env.example .env # 可选
npm run dev
```
访问 `http://localhost:3000/setup` 创建管理员。数据目录默认 `./data`(`DATA_DIR` 可改)。
## 常用命令
| 命令 | 说明 |
| ---- | ---- |
| `npm run dev` | 开发服务器 |
| `npm run build` | 生产构建 |
| `npm run start` | 运行 `.output` |
| `npm run lint` | ESLint |
| `npm run typecheck` | 类型检查 |
| `npm test` | 单元测试 |
| `npm run reset-password` | 重置密码(可选用户名) |
| `npm run migrate` | 预览遗留目录迁移(加 `-- --apply` 执行) |
## 开发专用环境变量
| 变量 | 说明 |
| ---- | ---- |
| `DEV_BYPASS_ACCESS` | 设为 `true` 可绕过登录(跳过 `/setup`,一般无需开启) |
| `DATA_DIR` | 数据目录,默认 `./data` |
完整变量列表见 [环境变量](./configuration.md)。
## 双域名本地测试
修改系统 `hosts` 后可用两个测试域名验证分离逻辑,步骤见 [双域名分离](./domain-separation.md#本地测试-windows-macos)。
## 文档站
```bash
npm run docs:dev # VitePress 开发
npm run docs:build # 构建静态站点
npm run docs:preview # 预览构建结果
```
文档源码在 `docs-site/`;截图源文件在 `docs/screenshots/`(含 `login.png` 登录页),构建前会复制到 `docs-site/public/screenshots/`。
+178
View File
@@ -0,0 +1,178 @@
# v1.2.0 本地磁盘迁移指南
v1.2.0 起,**所有本地图片文件**统一放在 `data/images/` 下;SQLite `images` 表中的 `key` 也以 `images/` 开头。旧版在 `data/blog/`、`data/twikoo/` 等与 `images` **并列的顶层目录**不再符合新模型。
迁移分 **两步**:先用手动 CLI **搬文件**,再由服务 **启动时自动同步索引**。
---
## 从其它图床迁入(EasyImage / 兰空 / Chevereto 等)
**没有**单独的「一键从外链拉取」或「对接某某图床 API」工具。从 **PicHost 旧版** 或其它自托管图床迁入,走的是同一套 **本地文件迁移**:
1. 在旧图床服务器上找到图片**物理目录**(整目录拷贝,不要只拷数据库)。
2. 把该目录放到 PicHost 的 `data/` 下,作为与 `images` **并列的顶层文件夹**(文件夹名任意,会保留为 `images/{文件夹名}/...`)。
3. 执行 `migrate` / `migrate --apply`,再重启 PicHost。
### 常见来源示例
| 来源 | 典型图片目录(在旧服务器上) | 放入 PicHost 后示例 |
| ---- | ---------------------------- | ------------------- |
| PicHost v1.1.x | `data/blog/`、`data/twikoo/` | 已在 `data/` 下,直接 `migrate` |
| EasyImage / EasyImages | `i/` 或安装目录下的 `upload/` | 拷到 `data/easyimage/` 再 `migrate` |
| 兰空图床 (Lsky) | `public/uploads/` 等 | 拷到 `data/lsky/` 再 `migrate` |
| Chevereto 等 | `images/` 或 `content/` | 拷到 `data/chevereto/` 再 `migrate` |
| 任意自定义目录 | 你的顶层文件夹名 | 拷到 `data/你的目录名/` 再 `migrate` |
脚本会扫描 `data/` 下**除 `images` 外所有顶层目录**里的图片,不限于 `blog` / `twikoo`。子目录结构会原样保留在 `images/{顶层名}/` 下。
### 操作示例(Docker)
```bash
# 在宿主机:把从旧图床打包的目录放进挂载卷(名称自定)
# 例如:./data/easyimage/2026/01/photo.jpg
docker exec pichost migrate # 预览
docker exec pichost migrate --apply
# 重启容器 → 启动日志会补全 SQLite 索引
```
### 说明与限制
- **只迁移文件**:旧图床的账号、Token、外链 URL **不会**自动导入;`mapping.json` 里的 key 是「旧磁盘相对 key → 新 key」,**不是**旧站完整 URL,替换外链需自行对照。
- **对象存储**:若旧图床图片在 S3/R2 桶里,请用桶工具同步到本地 `data/某目录/`,或直接在 PicHost **存储**页配置同一桶(不经过本 CLI)。
- **已直接在 `data/images/` 下**:无需 `migrate`,重启后启动同步会自动建索引。
---
## 存储结构对比
### 迁移前(v1.1.x 常见)
```
/data
├── pichost.db
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
├── twikoo/
│ └── xxx.webp
└── images/ # 可能已有部分新图
└── ...
```
SQLite 中 `key` 可能是 `blog/2026/08/TeKJ2fD6cB6j.webp`(与磁盘顶层目录一致)。
### 迁移后(v1.2.0)
```
/data
├── pichost.db
├── mapping.json # CLI 生成:旧 key → 新 key(供替换外链)
└── images/
├── blog/
│ └── 2026/08/TeKJ2fD6cB6j.webp
├── twikoo/
│ └── xxx.webp
└── ... # 新上传:images/随机ID.webp 或 images/年/月/随机ID.webp
```
对应 `key`:`images/blog/2026/08/TeKJ2fD6cB6j.webp`。
---
## 第一步:CLI 迁移磁盘文件
在 **升级镜像之前或之后**均可执行;建议在升级前完成,并先备份整个 `data/` 目录。
### Docker
```bash
# 预览:列出待迁移文件,写入 data/mapping.json(旧 key → 新 key)
docker exec pichost migrate
# 确认无误后执行文件移动
docker exec pichost migrate --apply
```
### 本地开发
```bash
npm run migrate # 预览
npm run migrate -- --apply # 执行
```
### 脚本行为
| 项目 | 说明 |
| ---- | ---- |
| 扫描范围 | `data/` 下**除 `images` 外所有顶层目录**(不限于 blog/twikoo) |
| 迁移目标 | `data/images/{原顶层目录名}/{相对路径}` |
| 图片类型 | `.jpg` `.jpeg` `.png` `.webp` `.gif` `.svg` `.ico` |
| 伴随文件 | 若存在同名的 `.meta.json` 会一并移动 |
| 非图片文件 | 不移动;若目录内仍有其它文件,**保留原目录**不删除 |
| 映射表 | `data/mapping.json`,例如 `"blog/foo.webp": "images/blog/foo.webp"` |
脚本路径:`server/cli/migrate-to-single-images.mjs`。
---
## 第二步:启动时自动同步索引
每次 PicHost 启动(`npm run dev`、Docker 容器启动)会自动执行,**无需手动命令**。日志示例:
```text
[INFO] 开始同步图片索引:扫描本地磁盘 {"path":".../data/images/"}
[INFO] 磁盘扫描完成:已补全缺失索引 {"inserted":3}
[INFO] 开始归一化遗留图片路径(如 blog/ → images/blog/)
[INFO] 遗留路径归一化完成:无需调整
[INFO] 开始校验索引与磁盘一致性,清理无文件的孤儿记录
[INFO] 孤儿索引清理完成:未发现孤儿记录
[INFO] 开始修复存量 Content-Type 元数据
[INFO] Content-Type 修复完成:无需修复
[INFO] 图片索引同步结束
```
| 步骤 | 作用 |
| ---- | ---- |
| 扫描 `data/images/` | 磁盘上有、索引里没有 → **补全** `images` 表 |
| 归一化遗留 key | 将 `blog/...` 等旧 key 更新为 `images/blog/...`(文件已在正确路径时) |
| 清理孤儿索引 | 索引里有记录、磁盘无文件 → **删除**幽灵记录(避免图库出现删不掉的项) |
| 修复 Content-Type | 修正错误的 `application/octet-stream` |
CLI 只负责**搬文件**;索引对齐、遗留 key、孤儿记录由启动同步处理。执行 `migrate --apply` 后**重启一次**服务即可。
---
## 推荐升级流程
1. **备份** `data/`(含 `pichost.db` 与各目录)。
2. `docker exec pichost migrate` 预览,检查输出与 `mapping.json`。
3. `docker exec pichost migrate --apply` 执行迁移。
4. 拉取 / 部署 **v1.2.0** 镜像并重启容器。
5. 查看启动日志,确认「图片索引同步结束」无报错。
6. 用 `mapping.json` 批量替换博客、Twikoo 等外链中的旧路径(若需要)。
仅使用对象存储、或图片早已全部在 `data/images/` 下时,**跳过第一步**;第二步仍会在启动时运行,通常几秒内完成且无变更日志。
---
## 外链与直链
| 场景 | 说明 |
| ---- | ---- |
| 旧 key | `blog/TeKJ2fD6cB6j.webp` |
| 新 key | `images/blog/TeKJ2fD6cB6j.webp` |
| 隐藏目录模式 | 若开启「隐藏 images 前缀」,短链可能为 `/TeKJ2fD6cB6j.webp`;服务按文件名反查索引 |
| 替换建议 | 以 `mapping.json` 为准;或重新在图库复制新链接 |
---
## 相关命令
| 命令 | 说明 |
| ---- | ---- |
| `docker exec pichost migrate` | 预览磁盘迁移 |
| `docker exec pichost migrate --apply` | 执行磁盘迁移 |
| `docker exec pichost reset-password` | 重置密码(与迁移无关) |
英文版:[v1.2 migration guide](/en/guide/migration)
+85
View File
@@ -0,0 +1,85 @@
# 反向代理
生产环境建议通过 Nginx、Caddy、NPM(Nginx Proxy Manager)或 NAS 自带反代,将 HTTPS 流量转发到 PicHost 容器 **6892** 端口。
## 基本要求
- 反代目标:`http://127.0.0.1:6892`(或容器所在主机 IP)
- 保留请求头:`Host`、`X-Forwarded-Proto`(及常见的 `X-Forwarded-For`)
- 上传大小:Nginx 建议 `client_max_body_size 12m` 或更大
- 公网访问请配置 **HTTPS**
## 单域名示例(Nginx)
管理、API、出图同一域名:
```nginx
server {
server_name pic.example.com;
location / {
proxy_pass http://127.0.0.1:6892;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 12m;
}
}
```
环境变量(可选):
```env
IMAGE_BASE_URL=https://pic.example.com
```
未设置 `IMAGE_BASE_URL` 时,PicHost 会根据反代转发的 `Host` 与 `X-Forwarded-Proto` 推断公网地址。
## 双域名
若后台与图片使用不同域名,两个 `server` 块均可 **全量反代** 到同一端口;隔离由 PicHost 中间件完成。
详见专章:[双域名分离](./domain-separation.md)。
## Cloudflare 橙云
PicHost 应用运行在 **自有服务器 / Docker**(端口 `6892`),Cloudflare 侧推荐:
| 做法 | 说明 |
| ---- | ---- |
| **DNS 橙云** | `admin` / `pic` 两个子域 Proxied → 源站反代 `6892`,SSL **Full (strict)** |
| **DNS 优选** | 与 PicHost 兼容;Host 仍为正式域名 |
| **R2** | 在存储页作对象后端,非 Pages 托管应用 |
| **源站防火墙** | 仅放行 [Cloudflare IP](https://www.cloudflare.com/ips/),禁止公网直连 `6892` |
**不建议:**
- 用 **Cloudflare Pages**(`*.pages.dev`)或 **Workers**(`*.workers.dev`)对 PicHost **整站反代** — 会多出第三 Host,且可能绕过双域名隔离(见 [双域名分离 · 安全与注意事项](./domain-separation.md#安全与注意事项))
- 在 CF 控制台「从 Git 创建 Worker」部署本仓库 — 当前为 `node-server` 预设,无法直接 `wrangler deploy`
## 源站安全(双域名推荐)
除 PicHost 中间件外,建议在反代 / 防火墙层加固:
1. **`server_name` 白名单** — 仅网站域与图片域;`default_server` 拒绝裸 IP 与其它 Host
2. **禁止公网暴露 6892** — 仅本机或内网反代访问;公网只开 80/443
3. **橙云时限制源站来源 IP** — 仅 Cloudflare 回源 IP 段
双域名场景下 PicHost **不会** 拒绝未配置的 Host;上述措施由运维层补齐。
## Caddy 示例
```caddy
pic.example.com {
reverse_proxy 127.0.0.1:6892
}
```
双域名时添加第二个站点块,后端地址相同即可。
## 缓存注意
图片 404/403 与域名隔离 404 响应带 **禁止缓存** 头,避免 CDN 长时间缓存错误页面。若在前端再加 CDN,请对动态 API 路径谨慎缓存。
## 相关
- [环境变量](./configuration.md) — `SITE_BASE_URL`、`IMAGE_BASE_URL`
- [快速开始](./getting-started.md) — Docker 端口
+72
View File
@@ -0,0 +1,72 @@
# 存储
PicHost 支持 **本地磁盘** 与 **S3 兼容对象存储**(Cloudflare R2、腾讯云 COS、阿里云 OSS、AWS S3 等)。管理员在 **存储**(`/storage`)页管理后端;新上传写入 **默认后端**。
![存储管理](/screenshots/storage.png)
## 存储结构(本地)
```
/data
├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
└── images/ # 本地后端时的全部图片文件
```
- 新上传:`images/随机ID.webp` 或 `images/年/月/随机ID.webp`(可在设置中切换扁平模式)
- 迁移遗留:`images/blog/...` 等子路径
- 云存储时文件在对应桶内,索引仍在 SQLite `images` 表
所有图片 **key** 以 `images/` 开头。上传 API **不再接受** `folder` 参数。
## 添加存储后端
1. 以管理员登录,打开 **存储** → **添加存储**。
2. 选择类型:**本地磁盘** 或 **S3 兼容**(含 R2 / COS / OSS 预设)。
3. 填写连接信息(桶名、Endpoint、Access Key 等)。
4. 保存后可 **设默认**,新上传将写入该后端。
### Cloudflare R2 要点
| 项 | 值 |
| -- | -- |
| Endpoint | `https://<account_id>.r2.cloudflarestorage.com` |
| Region | `auto` |
| 桶 | 你的 R2 桶名 |
| 密钥 | R2 API Token 的 Access Key / Secret Key |
若仓库 **cloudflare** 分支专用于「仅 R2」部署,也可用环境变量预设,见 [文档首页](./index.md) 分支说明。
### 环境变量(可选)
部分部署用环境变量初始化默认 S3 后端(如 `STORAGE_BACKEND=s3`、`S3_ENDPOINT`、`S3_BUCKET`、`S3_ACCESS_KEY`、`S3_SECRET_KEY`、`S3_REGION`)。**UI 中配置的后端信息存于 SQLite**,与环境变量并存时以管理界面为准管理多后端。
## 直链模式
每个后端可配置公开 URL 行为:
| 模式 | 说明 |
| ---- | ---- |
| **proxy** | 通过 PicHost 同源代理出图(`GET /images/...`) |
| **public** | 302 重定向到桶/CDN 公网地址 |
复制链接时使用 **图片域名**(`IMAGE_BASE_URL`)拼接路径。开启「隐藏 images 前缀」后 URL 可能更短,服务按文件名或路径反查索引。
## 用量与筛选
- 存储页展示各后端用量(本地扫描目录,对象存储查询 API)
- 图库可按存储后端筛选(管理员)
## 备份
请同时备份:
- `data/images/`(本地后端)
- `data/pichost.db`
- 各云桶中的对象
从 v1.1.x 升级且存在 `data/blog/` 等并列目录时,见 [v1.2 迁移](./migration.md)。
## 相关
- [环境变量](./configuration.md) — `STORAGE_USE_DATE_PATH`、`HIDE_FOLDER_IN_URL`
- [用户与权限](./users-and-permissions.md) — 谁可以管理存储
+53
View File
@@ -0,0 +1,53 @@
# Twikoo
PicHost 兼容 **Twikoo** 与 **EasyImage 2.0** 图片上传协议,评论系统可像接 EasyImage 一样配置。
## Twikoo 配置
在 Twikoo 环境变量或配置中设置:
| 配置项 | 值 |
| ------ | -- |
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://你的域名/api/index.php` |
| `IMAGE_CDN_TOKEN` | 与全局 `API_UPLOAD_TOKEN` 相同 |
- 使用 **网站域名**(管理后台域)作为 `IMAGE_CDN_URL` 的主机名
- Token 需在 PicHost **API** 页生成,或通过 `API_UPLOAD_TOKEN` 环境变量设置
## 上传接口
`POST /api/index.php`
表单字段:
| 字段 | 说明 |
| ---- | ---- |
| `image` | 图片文件 |
| `token` | 全局 API Token(与 `Auth-Token` 等效) |
```bash
curl -X POST "https://admin.example.com/api/index.php" \
-F "token=YOUR_TOKEN" \
-F "image=@./demo.png"
```
上传的图片存入 `images/` 目录,归属与 **全局 Token** 相同(管理员)。
## 与 REST API 的区别
| 项目 | REST `/api/images/upload` | Twikoo `/api/index.php` |
| ---- | ------------------------- | ------------------------ |
| 鉴权 | `Auth-Token` 请求头 | 表单 `token` |
| 响应格式 | PicHost JSON | EasyImage 兼容 JSON |
| Token | 全局或个人 | 仅全局 Token |
## 双域名部署
Twikoo 上传应走 **网站域名**(`SITE_BASE_URL`),图片展示走 **图片域名**(`IMAGE_BASE_URL`)。详见 [双域名分离](./domain-separation.md)。
## 相关
- [API](./api.md)
- [用户与权限](./users-and-permissions.md)
- [环境变量](./configuration.md)
+58
View File
@@ -0,0 +1,58 @@
# 用户与权限
PicHost 使用基于角色的访问控制(RBAC):**管理员(admin)** 与 **普通用户(user)**。权限在服务端强制,不可仅靠前端绕过。
## 角色能力
| 能力 | 管理员 | 普通用户 |
| ---- | :----: | :------: |
| 上传图片 | ✓ | ✓ |
| 查看自己的图库 | ✓ | ✓ |
| 查看全部图库 / 上传者标签 | ✓ | — |
| 存储后端管理(`/storage`) | ✓ | — |
| 系统设置(`/settings`) | ✓ | — |
| 开放注册开关 | ✓ | — |
| 全局 API Token 管理 | ✓ | — |
| 个人 API Token | ✓ | ✓ |
| 统计页(含注册用户数) | ✓ | 部分 |
| 操作日志 | ✓ | — |
普通用户顶栏:**API**、**统计**;用户菜单:**修改密码**、**退出登录**。
## 鉴权场景
| 场景 | 鉴权方式 | 图片归属 / 可见范围 |
| ---- | -------- | ------------------- |
| 网页上传 | Session Cookie | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` 头 | Token 所属用户 |
| API 上传 + **全局 Token** | `Auth-Token` 头 | 管理员(`userId` 为空) |
| Twikoo `POST /api/index.php` | 表单 `token` | 与全局 Token 相同 |
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
## 注册与账号
- 默认仅管理员可创建用户;管理员可在设置中 **开放注册**
- 密码使用 scrypt 哈希存储
- Web 登录需先完成滑动拼图验证(拖动滑块对齐缺口)
- 登录失败有速率限制
## 上传偏好与自动删除
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接等,按用户保存
- **自动删除**:管理员可设全局策略;用户可设个人策略。启用后仅影响 **之后新上传** 的图片
## API Token 说明
| 类型 | 谁管理 | 上传归属 | 典型用途 |
| ---- | ------ | -------- | -------- |
| **全局 Token** | 管理员(API 页) | 管理员账号 | Twikoo、站点级脚本 |
| **个人 Token** | 各用户(API 页) | 该用户 | 个人博客、私有脚本 |
若设置了环境变量 `API_UPLOAD_TOKEN`,则全局 Token 被锁定,见 [环境变量](./configuration.md)。
## 相关
- [API](./api.md)
- [Twikoo](./twikoo.md)
- [存储](./storage.md)
+32
View File
@@ -0,0 +1,32 @@
---
layout: home
hero:
name: PicHost
text: 个人轻量图床
tagline: 自托管 · 多用户 · Docker · API · Twikoo · 本地磁盘或对象存储
actions:
- theme: brand
text: 快速开始
link: /guide/getting-started
- theme: alt
text: 查看 API
link: /guide/api
- theme: alt
text: GitHub
link: https://github.com/O96u/PicHost
features:
- title: 拖拽 / 粘贴上传
details: 支持点击、拖拽与 Ctrl+V 粘贴;服务端 WebP 压缩与 Referer 防盗链。
- title: 多后端存储
details: 本地磁盘 + S3 兼容(R2 / COS / OSS / AWS);proxy 或 public 直链模式。
- title: 多用户与权限
details: 管理员与普通用户 RBAC;全局 Token 与个人 Token;图库按归属隔离。
- title: Docker 零配置
details: 首次访问 Web 引导创建管理员;`docker exec pichost reset-password` 重置密码。
- title: 双域名分离
details: 单实例 + 两个域名全量反代;应用内中间件隔离后台与图片域。
- title: Twikoo 兼容
details: "POST /api/index.php 兼容 EasyImage 2.0 协议,评论系统一键接入。"
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+3
View File
@@ -2,5 +2,8 @@
import withNuxt from './.nuxt/eslint.config.mjs' import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt( export default withNuxt(
{
ignores: ['docs-site/**']
}
// Your custom configs here // Your custom configs here
) )
+158 -21
View File
@@ -10,6 +10,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"copy": "Copy", "copy": "Copy",
"delete": "Delete", "delete": "Delete",
"edit": "Edit",
"search": "Search", "search": "Search",
"refresh": "Refresh", "refresh": "Refresh",
"back": "Back", "back": "Back",
@@ -17,12 +18,15 @@
"notConfigured": "(not configured)", "notConfigured": "(not configured)",
"countImages": "{count} images", "countImages": "{count} images",
"itemsUnit": "items", "itemsUnit": "items",
"imagesUnit": "images" "imagesUnit": "images",
"more": "More"
}, },
"nav": { "nav": {
"api": "API", "api": "API",
"stats": "Stats", "stats": "Stats",
"logs": "Activity log",
"settings": "Settings", "settings": "Settings",
"storage": "Storage",
"menu": "Menu", "menu": "Menu",
"language": "Language", "language": "Language",
"appearance": "Appearance", "appearance": "Appearance",
@@ -60,6 +64,8 @@
"loginFailedSecret": "Sign-in failed. Check your secret", "loginFailedSecret": "Sign-in failed. Check your secret",
"loginFailedCredentials": "Invalid username or password", "loginFailedCredentials": "Invalid username or password",
"loginFailed": "Sign-in failed. Check username and password", "loginFailed": "Sign-in failed. Check username and password",
"captchaHint": "Drag the slider to the slot",
"captchaSuccess": "Verified",
"registerFailed": "Registration failed. Check your input", "registerFailed": "Registration failed. Check your input",
"setupFailed": "Setup failed. Check your input", "setupFailed": "Setup failed. Check your input",
"migrateFailed": "Migration failed. Check your input", "migrateFailed": "Migration failed. Check your input",
@@ -79,6 +85,17 @@
"passwordPlaceholder": "Set login password", "passwordPlaceholder": "Set login password",
"confirmPlaceholder": "Enter password again", "confirmPlaceholder": "Enter password again",
"allowRegistration": "Allow user registration", "allowRegistration": "Allow user registration",
"domainSeparation": "Separate admin and image domains",
"domainSeparationHint": "Admin and API use the site URL; image links use a dedicated image URL",
"domainSeparationProxyHint": "Point both domains to this host and reverse-proxy to port 6892 (Nginx, Caddy, NPM, etc.)",
"domainSeparationProxyExample": "View example",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "Image URL",
"imageBaseUrlHint": "No trailing slash; public image link prefix",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"domainSeparationInvalid": "Enter valid site and image URLs with different hostnames",
"migrateSubmit": "Complete migration", "migrateSubmit": "Complete migration",
"initSubmit": "Create and continue", "initSubmit": "Create and continue",
"migrateSuccess": "Migration complete", "migrateSuccess": "Migration complete",
@@ -164,18 +181,27 @@
"settings": { "settings": {
"title": "System settings", "title": "System settings",
"subtitle": "Saved to database; environment variables take precedence", "subtitle": "Saved to database; environment variables take precedence",
"uploadAccount": "Upload & accounts", "featureToggles": "Feature toggles",
"defaultFolder": "Default upload folder",
"defaultFolderHint": "Stored in this browser only; affects your upload target",
"allowRegistration": "Open registration", "allowRegistration": "Open registration",
"allowRegistrationHint": "Allow new users to register (regular users see only their images)", "allowRegistrationHint": "Allow new users to register (regular users see only their images)",
"accessControl": "Access control", "accessControl": "Access control",
"refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction", "refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction",
"refererPlaceholder": "example.com, www.example.com", "refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "Link generation", "domainSettings": "Domain settings",
"imageBaseUrlHint": "Public URL prefix for generated links; use your public domain behind a reverse proxy", "domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI and API upload endpoint",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "Active: {url}",
"imageBaseUrl": "Image URL",
"imageBaseUrlHint": "No trailing slash; public image link prefix",
"imageBaseUrlSingleHint": "No trailing slash; leave empty to use the current request host; single-domain setups do not need a site URL",
"imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "Active: {url}", "imageBaseUrlActive": "Active: {url}",
"hideFolderInUrl": "Hide folder in image URLs",
"hideFolderInUrlHint": "Links become /file.webp without folder or date paths; legacy full paths still work. Identical filenames under different subpaths may collide on short URLs",
"storageUseDatePath": "Group uploads by year/month",
"storageUseDatePathHint": "When enabled, files are stored as images/2026/08/file.webp; when disabled, as images/file.webp. Applies only to new uploads after enabling",
"badgeEnv": ".env", "badgeEnv": ".env",
"badgeSaved": "Saved", "badgeSaved": "Saved",
"badgeDefault": "Default", "badgeDefault": "Default",
@@ -183,7 +209,9 @@
"tokenEnv": "Environment", "tokenEnv": "Environment",
"tokenDb": "Generated in app", "tokenDb": "Generated in app",
"tokenNone": "Not configured", "tokenNone": "Not configured",
"apiTokenLine": "API Token · {source} · v{version}", "apiTokenLine": "API Token · {source} ·",
"viewReleases": "View releases",
"updateAvailable": "Update available: {version}",
"unsavedChanges": "Unsaved changes", "unsavedChanges": "Unsaved changes",
"manageToken": "Manage token", "manageToken": "Manage token",
"envOverride": "Overridden by environment variable; cannot edit in UI", "envOverride": "Overridden by environment variable; cannot edit in UI",
@@ -191,6 +219,86 @@
"saveFailed": "Failed to save settings", "saveFailed": "Failed to save settings",
"loadFailed": "Failed to load settings" "loadFailed": "Failed to load settings"
}, },
"storage": {
"title": "Storage",
"subtitle": "New uploads go to the active backend; existing images keep their backend",
"activeBackend": "Active backend",
"backendLocal": "Local disk",
"backendCloud": "Cloud storage",
"backendS3": "Cloud storage",
"switchHint": "Switching only affects new uploads",
"provider": "Preset",
"providerR2": "Cloudflare R2",
"providerCos": "Tencent COS",
"providerOss": "Alibaba OSS",
"providerAws": "AWS S3",
"providerCustom": "Custom",
"endpoint": "Endpoint",
"region": "Region",
"bucket": "Bucket",
"prefix": "Key prefix (optional)",
"prefixPlaceholder": "e.g. pichost",
"forcePathStyle": "Force path-style access",
"accessKey": "Access Key",
"secretKey": "Secret Key",
"servingMode": "Serving mode",
"servingProxy": "Proxy (same origin)",
"servingPublic": "Public URL (302 to CDN)",
"servingHint": "In public mode, configure referer protection on CDN/bucket",
"publicUrl": "Public base URL",
"publicUrlPlaceholder": "https://cdn.example.com",
"testConnection": "Test connection",
"testSuccess": "Connection successful",
"testFailed": "Connection failed",
"saved": "Storage settings saved",
"saveFailed": "Failed to save storage settings",
"loadFailed": "Failed to load storage settings",
"envOverride": "Storage is overridden by environment variables",
"pageTitle": "Storage management",
"pageSubtitle": "Manage backends; new uploads go to the default backend",
"addBackend": "Add storage",
"editBackend": "Edit storage",
"formCreateHint": "Configure cloud storage; set as default after saving to receive new uploads",
"formEditHint": "Update connection settings; leave keys blank to keep existing secrets",
"sectionBasic": "Basic",
"sectionConnection": "Connection",
"sectionCredentials": "Credentials",
"sectionAccess": "Access",
"sectionQuota": "Quota",
"advancedOptions": "Advanced",
"servingProxyShort": "Proxy",
"servingPublicShort": "Public",
"backendName": "Display name",
"badgeDefault": "Default",
"badgeDisabled": "Disabled",
"badgeEnabled": "Enabled",
"statTotal": "Total",
"statUsed": "Used",
"statFree": "Free",
"usageRate": "Usage",
"indexImages": "PicHost images {count} · {size}",
"setDefault": "Set as default",
"actionSetDefault": "Default",
"actionTest": "Test",
"disable": "Disable",
"enable": "Enable",
"usageCount": "{count} images",
"quotaTotal": "Plan size",
"quotaPlaceholder": "e.g. 10",
"quotaHint": "Enter your cloud plan size; used space is calculated from PicHost image index",
"quotaRequired": "Enter a valid plan size",
"notConfigured": "Not configured",
"created": "Storage backend added",
"deleted": "Storage backend deleted",
"deleteFailed": "Delete failed",
"deleteBlockedImages": "This backend still has images. Move or delete them before removing the backend.",
"defaultUpdated": "Default backend updated",
"deleteConfirmTitle": "Delete storage",
"deleteConfirmBody": "Delete “{name}”? This cannot be undone.",
"secretsRequired": "Access Key and Secret Key are required for new storage",
"manageStorage": "Manage object storage",
"manageStorageHint": "Configure R2 / S3 / COS backends and URL serving"
},
"stats": { "stats": {
"title": "Statistics", "title": "Statistics",
"subtitle": "Overview and gallery", "subtitle": "Overview and gallery",
@@ -203,12 +311,13 @@
"deleteTotal": "Total deleted", "deleteTotal": "Total deleted",
"userCount": "Registered users", "userCount": "Registered users",
"uploadBytes": "Total upload size", "uploadBytes": "Total upload size",
"folderDistribution": "Uploads by folder", "sourceDistribution": "Uploads by source",
"folderUsage": "Storage by folder", "sourceWeb": "Web",
"sourceApi": "API",
"noData": "No data", "noData": "No data",
"folderCount": "{count} · {size}",
"gallery": "Gallery", "gallery": "Gallery",
"folder": "Folder", "filterStorageAll": "All storage",
"storage": "Storage",
"searchPlaceholder": "Search filename", "searchPlaceholder": "Search filename",
"clear": "Clear", "clear": "Clear",
"selected": "{n} selected", "selected": "{n} selected",
@@ -220,15 +329,43 @@
"deletePartialFail": "{n} image(s) failed to delete", "deletePartialFail": "{n} image(s) failed to delete",
"deleteFailed": "Delete failed", "deleteFailed": "Delete failed",
"deletedSingle": "Deleted", "deletedSingle": "Deleted",
"allFolders": "All",
"counting": "Counting…", "counting": "Counting…",
"summarySearch": "{folder} · Search “{q}” · {total} images", "summarySearch": "Search “{q}” · {total} images",
"summaryTotal": "{folder} · {total} images" "summaryTotal": "{total} images",
"summarySearchStorage": "{storage} · Search “{q}” · {total} images",
"summaryTotalStorage": "{storage} · {total} images"
},
"logs": {
"pageTitle": "Activity log",
"pageSubtitle": "Your upload and delete history",
"pageSubtitleAdmin": "Upload and delete history for all users",
"loadFailed": "Failed to load activity log",
"empty": "No activity yet",
"entriesUnit": "entries",
"filterActionAll": "All actions",
"filterSourceAll": "All sources",
"filterUserAll": "All users",
"searchPlaceholder": "Search filename",
"summaryAll": "All · {total} entries, {upload} uploads, {delete} deletes",
"actionUpload": "Upload",
"actionDelete": "Delete",
"sourceWeb": "Web",
"sourceApi": "API",
"colTime": "Time",
"colUser": "User",
"colStorage": "Storage",
"colAction": "Action",
"colSource": "Source",
"colFile": "File",
"colFolder": "Folder",
"colSize": "Size",
"colKey": "Path"
}, },
"image": { "image": {
"empty": "No images", "empty": "No images",
"previewFailed": "Preview failed", "previewFailed": "Preview failed",
"uploader": "Uploader: {name}" "uploader": "Uploader: {name}",
"storageBackend": "Storage: {name}"
}, },
"delete": { "delete": {
"confirmTitle": "Confirm delete", "confirmTitle": "Confirm delete",
@@ -241,7 +378,9 @@
"first": "First page", "first": "First page",
"prev": "Previous page", "prev": "Previous page",
"next": "Next page", "next": "Next page",
"last": "Last page" "last": "Last page",
"goTo": "Go",
"pagePlaceholder": "Page"
}, },
"api": { "api": {
"tokenTitle": "API Token", "tokenTitle": "API Token",
@@ -272,13 +411,11 @@
"docs": { "docs": {
"upload": { "upload": {
"title": "Upload images", "title": "Upload images",
"descAdmin": "Upload one or more images. Use image field; folder creates directory if missing. Also accepts file, files, type.", "desc": "Upload one or more images via image field. Also accepts file, files. Stored under images/."
"descUser": "Upload one or more images via image field. Also accepts file, files. Stored under images/."
}, },
"list": { "list": {
"title": "List images", "title": "List images",
"descAdmin": "Paginated gallery; optional folder filter. limit default 20, max 100.", "desc": "Paginated gallery. limit default 20, max 100."
"descUser": "Paginated list of your images. limit default 20, max 100."
}, },
"search": { "search": {
"title": "Search images", "title": "Search images",
@@ -294,7 +431,7 @@
}, },
"twikoo": { "twikoo": {
"title": "Twikoo-compatible upload", "title": "Twikoo-compatible upload",
"desc": "Twikoo / EasyImage 2.0 protocol; stored under twikoo/. Fields: image + token." "desc": "Twikoo / EasyImage 2.0 protocol; stored under images/. Fields: image + token."
} }
} }
}, },
+158 -21
View File
@@ -10,6 +10,7 @@
"cancel": "取消", "cancel": "取消",
"copy": "复制", "copy": "复制",
"delete": "删除", "delete": "删除",
"edit": "编辑",
"search": "搜索", "search": "搜索",
"refresh": "刷新", "refresh": "刷新",
"back": "返回", "back": "返回",
@@ -17,12 +18,15 @@
"notConfigured": "(未配置)", "notConfigured": "(未配置)",
"countImages": "{count} 张", "countImages": "{count} 张",
"itemsUnit": "条", "itemsUnit": "条",
"imagesUnit": "张" "imagesUnit": "张",
"more": "更多"
}, },
"nav": { "nav": {
"api": "API", "api": "API",
"stats": "统计", "stats": "统计",
"logs": "操作日志",
"settings": "设置", "settings": "设置",
"storage": "存储",
"menu": "菜单", "menu": "菜单",
"language": "语言", "language": "语言",
"appearance": "外观", "appearance": "外观",
@@ -60,6 +64,8 @@
"loginFailedSecret": "登录失败,请检查密钥", "loginFailedSecret": "登录失败,请检查密钥",
"loginFailedCredentials": "用户名或密码错误", "loginFailedCredentials": "用户名或密码错误",
"loginFailed": "登录失败,请检查用户名和密码", "loginFailed": "登录失败,请检查用户名和密码",
"captchaHint": "拖动滑块对齐缺口",
"captchaSuccess": "验证通过",
"registerFailed": "注册失败,请检查输入", "registerFailed": "注册失败,请检查输入",
"setupFailed": "初始化失败,请检查输入", "setupFailed": "初始化失败,请检查输入",
"migrateFailed": "迁移失败,请检查输入", "migrateFailed": "迁移失败,请检查输入",
@@ -79,6 +85,17 @@
"passwordPlaceholder": "设置登录密码", "passwordPlaceholder": "设置登录密码",
"confirmPlaceholder": "再次输入密码", "confirmPlaceholder": "再次输入密码",
"allowRegistration": "允许其他用户注册", "allowRegistration": "允许其他用户注册",
"domainSeparation": "启用后台与图片域名分离",
"domainSeparationHint": "管理后台与 API 使用网站域名,图片直链使用独立图片域名",
"domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
"domainSeparationProxyExample": "查看示例",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"domainSeparationInvalid": "请填写有效的网站域名与图片域名,且两者不能相同",
"migrateSubmit": "完成迁移", "migrateSubmit": "完成迁移",
"initSubmit": "创建并进入", "initSubmit": "创建并进入",
"migrateSuccess": "账号迁移完成", "migrateSuccess": "账号迁移完成",
@@ -164,18 +181,27 @@
"settings": { "settings": {
"title": "系统设置", "title": "系统设置",
"subtitle": "保存后写入数据库;环境变量优先级更高", "subtitle": "保存后写入数据库;环境变量优先级更高",
"uploadAccount": "上传与账号", "featureToggles": "功能开关",
"defaultFolder": "默认上传目录",
"defaultFolderHint": "保存在本机浏览器,仅影响您的上传目标目录",
"allowRegistration": "开放用户注册", "allowRegistration": "开放用户注册",
"allowRegistrationHint": "允许新用户自行注册(普通用户仅可见自己的图片)", "allowRegistrationHint": "允许新用户自行注册(普通用户仅可见自己的图片)",
"accessControl": "访问控制", "accessControl": "访问控制",
"refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制", "refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制",
"refererPlaceholder": "example.com, www.example.com", "refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "链接生成", "domainSettings": "域名配置",
"imageBaseUrlHint": "生成图片链接的 URL 前缀,内网访问建议填公网域名", "domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "当前生效:{url}",
"imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
"imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名",
"imageBaseUrlPlaceholder": "https://pic.example.com", "imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "当前生效:{url}", "imageBaseUrlActive": "当前生效:{url}",
"hideFolderInUrl": "隐藏直链目录前缀",
"hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧完整路径仍可用。子路径下若存在同名文件,短链可能冲突",
"storageUseDatePath": "上传目录按年/月分组",
"storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传",
"badgeEnv": ".env", "badgeEnv": ".env",
"badgeSaved": "已保存", "badgeSaved": "已保存",
"badgeDefault": "默认", "badgeDefault": "默认",
@@ -183,7 +209,9 @@
"tokenEnv": "环境变量", "tokenEnv": "环境变量",
"tokenDb": "后台生成", "tokenDb": "后台生成",
"tokenNone": "未配置", "tokenNone": "未配置",
"apiTokenLine": "API Token · {source} · v{version}", "apiTokenLine": "API Token · {source} ·",
"viewReleases": "查看发行版",
"updateAvailable": "有新版本 {version}",
"unsavedChanges": "有未保存的更改", "unsavedChanges": "有未保存的更改",
"manageToken": "管理 Token", "manageToken": "管理 Token",
"envOverride": "该项已由环境变量覆盖,无法在后台修改", "envOverride": "该项已由环境变量覆盖,无法在后台修改",
@@ -191,6 +219,86 @@
"saveFailed": "保存服务器配置失败", "saveFailed": "保存服务器配置失败",
"loadFailed": "加载设置失败" "loadFailed": "加载设置失败"
}, },
"storage": {
"title": "存储设置",
"subtitle": "新上传写入活动后端;已有图片仍按原后端读取",
"activeBackend": "活动后端",
"backendLocal": "本地磁盘",
"backendCloud": "云存储",
"backendS3": "云存储",
"switchHint": "切换仅影响新上传,已有图片不受影响",
"provider": "预设",
"providerR2": "Cloudflare R2",
"providerCos": "腾讯云 COS",
"providerOss": "阿里云 OSS",
"providerAws": "AWS S3",
"providerCustom": "自定义",
"endpoint": "Endpoint",
"region": "Region",
"bucket": "Bucket",
"prefix": "对象前缀(可选)",
"prefixPlaceholder": "例如 pichost",
"forcePathStyle": "强制 Path-Style 访问",
"accessKey": "Access Key",
"secretKey": "Secret Key",
"servingMode": "访问方式",
"servingProxy": "代理(PicHost 同源)",
"servingPublic": "公开 URL(302 到 CDN)",
"servingHint": "公开模式下 Referer 防盗链需在 CDN/桶策略侧配置",
"publicUrl": "公开访问 URL",
"publicUrlPlaceholder": "https://cdn.example.com",
"testConnection": "测试连接",
"testSuccess": "连接成功",
"testFailed": "连接失败",
"saved": "存储配置已保存",
"saveFailed": "保存存储配置失败",
"loadFailed": "加载存储配置失败",
"envOverride": "存储配置已由环境变量覆盖,无法在后台修改",
"pageTitle": "存储管理",
"pageSubtitle": "管理多个存储后端,新上传写入默认后端",
"addBackend": "添加存储",
"editBackend": "编辑存储",
"formCreateHint": "配置云存储连接,保存后可设为默认写入目标",
"formEditHint": "修改连接信息;留空密钥则保持原值",
"sectionBasic": "基本信息",
"sectionConnection": "连接配置",
"sectionCredentials": "访问凭证",
"sectionAccess": "访问方式",
"sectionQuota": "容量配额",
"advancedOptions": "高级选项",
"servingProxyShort": "代理",
"servingPublicShort": "公开",
"backendName": "显示名称",
"badgeDefault": "默认",
"badgeDisabled": "已停用",
"badgeEnabled": "已启用",
"statTotal": "总量",
"statUsed": "已用",
"statFree": "剩余",
"usageRate": "使用率",
"indexImages": "PicHost 图片 {count} 张 · {size}",
"setDefault": "设为默认",
"actionSetDefault": "设默认",
"actionTest": "测试",
"disable": "停用",
"enable": "启用",
"usageCount": "{count} 张图片",
"quotaTotal": "套餐总量",
"quotaPlaceholder": "例如 10",
"quotaHint": "填写云存储套餐总量;已用按 PicHost 图片索引自动统计",
"quotaRequired": "请填写有效的套餐总量",
"notConfigured": "未配置",
"created": "存储后端已添加",
"deleted": "存储后端已删除",
"deleteFailed": "删除失败",
"deleteBlockedImages": "该后端仍有图片,请先迁移或删除图片后再删除",
"defaultUpdated": "默认后端已更新",
"deleteConfirmTitle": "删除存储",
"deleteConfirmBody": "确定删除「{name}」?此操作不可撤销。",
"secretsRequired": "新建存储需填写 Access Key 与 Secret Key",
"manageStorage": "管理对象存储",
"manageStorageHint": "配置 R2 / S3 / COS 等多后端与直链策略"
},
"stats": { "stats": {
"title": "统计", "title": "统计",
"subtitle": "数据概览与图库记录", "subtitle": "数据概览与图库记录",
@@ -203,12 +311,13 @@
"deleteTotal": "累计删除", "deleteTotal": "累计删除",
"userCount": "注册用户数量", "userCount": "注册用户数量",
"uploadBytes": "累计上传体积", "uploadBytes": "累计上传体积",
"folderDistribution": "上传目录分布", "sourceDistribution": "上传来源分布",
"folderUsage": "目录占用", "sourceWeb": "网页",
"sourceApi": "API",
"noData": "暂无数据", "noData": "暂无数据",
"folderCount": "{count} 张 · {size}",
"gallery": "图库记录", "gallery": "图库记录",
"folder": "目录", "filterStorageAll": "全部存储",
"storage": "存储",
"searchPlaceholder": "搜索文件名", "searchPlaceholder": "搜索文件名",
"clear": "清除", "clear": "清除",
"selected": "已选 {n}", "selected": "已选 {n}",
@@ -220,15 +329,43 @@
"deletePartialFail": "{n} 张图片删除失败", "deletePartialFail": "{n} 张图片删除失败",
"deleteFailed": "删除失败", "deleteFailed": "删除失败",
"deletedSingle": "已删除", "deletedSingle": "已删除",
"allFolders": "全部",
"counting": "统计中…", "counting": "统计中…",
"summarySearch": "{folder} · 搜索「{q}」· 共 {total} 张", "summarySearch": "搜索「{q}」· 共 {total} 张",
"summaryTotal": "{folder} · 共 {total} 张" "summaryTotal": "共 {total} 张",
"summarySearchStorage": "{storage} · 搜索「{q}」· 共 {total} 张",
"summaryTotalStorage": "{storage} · 共 {total} 张"
},
"logs": {
"pageTitle": "操作日志",
"pageSubtitle": "查看您的上传与删除记录",
"pageSubtitleAdmin": "查看所有用户的上传与删除记录",
"loadFailed": "加载操作日志失败",
"empty": "暂无操作记录",
"entriesUnit": "条记录",
"filterActionAll": "全部操作",
"filterSourceAll": "全部来源",
"filterUserAll": "全部用户",
"searchPlaceholder": "搜索文件名",
"summaryAll": "全部 · 共 {total} 条记录,上传 {upload} 条,删除 {delete} 条",
"actionUpload": "上传",
"actionDelete": "删除",
"sourceWeb": "网页",
"sourceApi": "API",
"colTime": "时间",
"colUser": "用户",
"colStorage": "存储",
"colAction": "操作",
"colSource": "来源",
"colFile": "文件名",
"colFolder": "目录",
"colSize": "大小",
"colKey": "路径"
}, },
"image": { "image": {
"empty": "暂无图片", "empty": "暂无图片",
"previewFailed": "预览失败", "previewFailed": "预览失败",
"uploader": "上传者:{name}" "uploader": "上传者:{name}",
"storageBackend": "存储:{name}"
}, },
"delete": { "delete": {
"confirmTitle": "确认删除", "confirmTitle": "确认删除",
@@ -241,7 +378,9 @@
"first": "第一页", "first": "第一页",
"prev": "上一页", "prev": "上一页",
"next": "下一页", "next": "下一页",
"last": "最后一页" "last": "最后一页",
"goTo": "跳转",
"pagePlaceholder": "页码"
}, },
"api": { "api": {
"tokenTitle": "API Token", "tokenTitle": "API Token",
@@ -272,13 +411,11 @@
"docs": { "docs": {
"upload": { "upload": {
"title": "上传图片", "title": "上传图片",
"descAdmin": "上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。", "desc": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片存入 images/ 目录。"
"descUser": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。"
}, },
"list": { "list": {
"title": "获取图片列表", "title": "获取图片列表",
"descAdmin": "分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。", "desc": "分页列出图库,limit 默认 20、最大 100。"
"descUser": "分页列出您的图库,limit 默认 20、最大 100。"
}, },
"search": { "search": {
"title": "搜索图片", "title": "搜索图片",
@@ -294,7 +431,7 @@
}, },
"twikoo": { "twikoo": {
"title": "twikoo 兼容上传", "title": "twikoo 兼容上传",
"desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 twikoo/ 目录;字段 image + token。" "desc": "兼容 Twikoo / EasyImage 2.0 协议,图片存入 images/ 目录;字段 image + token。"
} }
} }
}, },
+12
View File
@@ -30,10 +30,16 @@ export default defineNuxtConfig({
appVersion: pkg.version, appVersion: pkg.version,
devBypassAccess: process.env.DEV_BYPASS_ACCESS === 'true', devBypassAccess: process.env.DEV_BYPASS_ACCESS === 'true',
adminSecret: '', adminSecret: '',
siteBaseUrl: '',
imageBaseUrl: '', imageBaseUrl: '',
apiUploadToken: '' apiUploadToken: ''
}, },
devServer: {
host: '127.0.0.1',
port: 3000
},
compatibilityDate: '2026-06-30', compatibilityDate: '2026-06-30',
nitro: { nitro: {
@@ -43,6 +49,12 @@ export default defineNuxtConfig({
} }
}, },
vite: {
server: {
allowedHosts: true
}
},
eslint: { eslint: {
config: { config: {
stylistic: { stylistic: {
+2169 -3
View File
File diff suppressed because it is too large. Load diff
+11 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "pichost", "name": "pichost",
"version": "1.0.4", "version": "1.2.1",
"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.", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme", "homepage": "https://github.com/O96u/PicHost#readme",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -19,7 +19,7 @@
"twikoo", "twikoo",
"easyimage" "easyimage"
], ],
"license": "MIT", "license": "GPL-3.0-only",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "npm@11.12.1", "packageManager": "npm@11.12.1",
@@ -33,9 +33,15 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"start": "node .output/server/index.mjs", "start": "node .output/server/index.mjs",
"reset-password": "node server/cli/reset-password.mjs" "reset-password": "node server/cli/reset-password.mjs",
"migrate": "node server/cli/migrate-to-single-images.mjs",
"docs:prepare": "node docs-site/copy-screenshots.mjs",
"docs:dev": "npm run docs:prepare && vitepress dev docs-site",
"docs:build": "npm run docs:prepare && vitepress build docs-site",
"docs:preview": "npm run docs:prepare && vitepress preview docs-site"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.876.0",
"@iconify-json/lucide": "^1.2.114", "@iconify-json/lucide": "^1.2.114",
"@nuxt/ui": "^4.9.0", "@nuxt/ui": "^4.9.0",
"@nuxtjs/i18n": "^10.6.0", "@nuxtjs/i18n": "^10.6.0",
@@ -48,6 +54,7 @@
"@types/node": "^22.15.0", "@types/node": "^22.15.0",
"eslint": "^10.6.0", "eslint": "^10.6.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vitepress": "^1.6.4",
"vitest": "^3.2.4", "vitest": "^3.2.4",
"vue-tsc": "^3.3.5" "vue-tsc": "^3.3.5"
} }
+39
View File
@@ -0,0 +1,39 @@
import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import {
deleteStorageBackend,
isStorageEnvConfigured,
LOCAL_BACKEND_ID
} from '../../../utils/storage-backends'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isStorageEnvConfigured()) {
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
}
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
}
if (id === LOCAL_BACKEND_ID) {
createApiError(event, 'INVALID_REQUEST', '本地磁盘后端不可删除', 400)
}
try {
deleteStorageBackend(id)
} catch (error) {
const message = error instanceof Error ? error.message : '删除失败'
if (message.includes('stored images')) {
createApiError(event, 'INVALID_REQUEST', '该后端仍有图片,无法删除', 400)
}
if (message.includes('default backend')) {
createApiError(event, 'INVALID_REQUEST', '请先切换默认后端后再删除', 400)
}
createApiError(event, 'INVALID_REQUEST', message, 400)
}
return { success: true }
})
+129
View File
@@ -0,0 +1,129 @@
import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import {
isStorageEnvConfigured,
LOCAL_BACKEND_ID,
updateStorageBackend,
getStorageBackendRow,
setDefaultBackend,
type StorageBackendPatch
} from '../../../utils/storage-backends'
import { parseS3Config, validateS3Config } from '../../../utils/storage/s3'
import { parseQuotaBytes } from '../../../utils/storage-capacity'
import type { ServingMode } from '../../../utils/storage/types'
interface StoragePatchBody {
name?: string
config?: {
endpoint?: string
region?: string
bucket?: string
prefix?: string
forcePathStyle?: boolean
}
secrets?: {
accessKeyId?: string
secretAccessKey?: string
}
servingMode?: ServingMode
publicUrl?: string
quotaBytes?: number | null
enabled?: boolean
isDefault?: boolean
}
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isStorageEnvConfigured()) {
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
}
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
}
const row = getStorageBackendRow(id)
if (!row) {
createApiError(event, 'INVALID_REQUEST', '存储后端不存在', 404)
}
const body = await readBody<StoragePatchBody>(event).catch(
(): StoragePatchBody => ({})
)
if (id === LOCAL_BACKEND_ID && body.isDefault === false) {
createApiError(event, 'INVALID_REQUEST', '必须保留一个默认存储后端', 400)
}
const patch: StorageBackendPatch = {}
if (body.name !== undefined) {
patch.name = body.name.trim() || row.name
}
if (body.config !== undefined && row.type === 's3') {
const current = parseS3Config(row.config_json) ?? {
endpoint: '',
region: 'auto',
bucket: ''
}
const next = {
endpoint: body.config.endpoint?.trim() ?? current.endpoint,
region: body.config.region?.trim() ?? current.region,
bucket: body.config.bucket?.trim() ?? current.bucket,
prefix: body.config.prefix?.trim() || undefined,
forcePathStyle: body.config.forcePathStyle ?? current.forcePathStyle
}
const error = validateS3Config(next)
if (error) {
createApiError(event, 'INVALID_REQUEST', error, 400)
}
patch.config = next
}
if (body.secrets !== undefined && row.type === 's3') {
patch.secrets = body.secrets
}
if (body.servingMode !== undefined) {
if (body.servingMode !== 'proxy' && body.servingMode !== 'public') {
createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400)
}
patch.servingMode = body.servingMode
}
if (body.publicUrl !== undefined) {
patch.publicUrl = body.publicUrl.trim()
}
if (body.quotaBytes !== undefined) {
if (row.type === 's3') {
const quotaBytes = parseQuotaBytes(body.quotaBytes)
if (!quotaBytes) {
createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400)
}
patch.quotaBytes = quotaBytes
}
}
if (body.enabled !== undefined) {
if (id === LOCAL_BACKEND_ID && !body.enabled) {
createApiError(event, 'INVALID_REQUEST', '本地磁盘后端不可停用', 400)
}
patch.enabled = body.enabled
}
if (body.isDefault !== undefined) {
patch.isDefault = body.isDefault
}
updateStorageBackend(id, patch)
if (body.isDefault) {
setDefaultBackend(id)
}
return { success: true }
})
@@ -0,0 +1,34 @@
import { requireAdminAuth } from '../../../../utils/access'
import { createApiError } from '../../../../utils/api-error'
import {
createBackendInstance,
getStorageBackendRow,
isStorageEnvConfigured
} from '../../../../utils/storage-backends'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isStorageEnvConfigured()) {
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台测试', 409)
}
const id = getRouterParam(event, 'id')
if (!id) {
createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
}
const row = getStorageBackendRow(id)
if (!row) {
createApiError(event, 'INVALID_REQUEST', '存储后端不存在', 404)
}
try {
const backend = createBackendInstance(row)
const result = await backend.testConnection()
return result
} catch (error) {
const message = error instanceof Error ? error.message : '连接失败'
return { ok: false, message }
}
})
+39
View File
@@ -0,0 +1,39 @@
import { requireAdminAuth } from '../../../utils/access'
import {
getBackendUsageStats,
isStorageEnvConfigured,
listStorageBackends
} from '../../../utils/storage-backends'
import { buildBackendCapacity } from '../../../utils/storage-capacity'
import { getActiveBackendRow } from '../../../utils/storage/resolver'
import type { StorageBackendInfo } from '../../../utils/storage/types'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const rows = listStorageBackends()
const backends = await Promise.all(rows.map(async (backend: StorageBackendInfo & {
secretsMasked: Record<string, string>
}) => {
const usage = getBackendUsageStats(backend.id)
const capacity = await buildBackendCapacity(
backend.id,
backend.type,
usage.bytes,
backend.quotaBytes
)
return {
...backend,
usage,
capacity
}
}))
const activeBackend = getActiveBackendRow()
return {
backends,
activeBackendId: activeBackend?.id ?? 'local',
envOverride: isStorageEnvConfigured()
}
})
+83
View File
@@ -0,0 +1,83 @@
import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import {
insertStorageBackend,
isStorageEnvConfigured
} from '../../../utils/storage-backends'
import { validateS3Config } from '../../../utils/storage/s3'
import { parseQuotaBytes } from '../../../utils/storage-capacity'
import type { ServingMode } from '../../../utils/storage/types'
interface StorageCreateBody {
name?: string
config?: {
endpoint?: string
region?: string
bucket?: string
prefix?: string
forcePathStyle?: boolean
}
secrets?: {
accessKeyId?: string
secretAccessKey?: string
}
servingMode?: ServingMode
publicUrl?: string
quotaBytes?: number | null
enabled?: boolean
isDefault?: boolean
}
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isStorageEnvConfigured()) {
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
}
const body = await readBody<StorageCreateBody>(event).catch(
(): StorageCreateBody => ({})
)
const name = body.name?.trim() || 'S3 兼容存储'
const config = {
endpoint: body.config?.endpoint?.trim() ?? '',
region: body.config?.region?.trim() || 'auto',
bucket: body.config?.bucket?.trim() ?? '',
prefix: body.config?.prefix?.trim() || undefined,
forcePathStyle: body.config?.forcePathStyle ?? false
}
const error = validateS3Config(config)
if (error) {
createApiError(event, 'INVALID_REQUEST', error, 400)
}
const accessKeyId = body.secrets?.accessKeyId?.trim() ?? ''
const secretAccessKey = body.secrets?.secretAccessKey?.trim() ?? ''
if (!accessKeyId || !secretAccessKey) {
createApiError(event, 'INVALID_REQUEST', 'Access Key 与 Secret Key 不能为空', 400)
}
if (body.servingMode && body.servingMode !== 'proxy' && body.servingMode !== 'public') {
createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400)
}
const quotaBytes = parseQuotaBytes(body.quotaBytes)
if (!quotaBytes) {
createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400)
}
const id = insertStorageBackend({
name,
config,
secrets: { accessKeyId, secretAccessKey },
servingMode: body.servingMode,
publicUrl: body.publicUrl?.trim(),
quotaBytes,
enabled: body.enabled,
isDefault: body.isDefault
})
return { success: true, id }
})
+6
View File
@@ -0,0 +1,6 @@
import { createLoginCaptchaChallenge } from '../../utils/login-captcha'
export default defineEventHandler(() => {
const { id, svg } = createLoginCaptchaChallenge()
return { id, svg }
})
+19
View File
@@ -0,0 +1,19 @@
import { createApiError } from '../../../utils/api-error'
import { confirmLoginCaptcha } from '../../../utils/login-captcha'
interface ConfirmBody {
id?: string
captchaPosition?: number
}
export default defineEventHandler(async (event) => {
const body = await readBody<ConfirmBody>(event)
const id = body?.id?.trim() ?? ''
const position = Number(body?.captchaPosition)
if (!id || !confirmLoginCaptcha(id, position)) {
createApiError(event, 'INVALID_REQUEST', '滑块未对齐,请重试', 400)
}
return { success: true }
})
+13
View File
@@ -1,3 +1,4 @@
import type { H3Event } from 'h3'
import { import {
checkAdminSecret, checkAdminSecret,
setLegacyAuthCookie setLegacyAuthCookie
@@ -10,12 +11,15 @@ import {
} from '../../utils/auth' } from '../../utils/auth'
import { findUserByUsername } from '../../utils/db' import { findUserByUsername } from '../../utils/db'
import { getAdminSecret } from '../../utils/env' import { getAdminSecret } from '../../utils/env'
import { verifyLoginCaptcha } from '../../utils/login-captcha'
import { clientIp, logInfo, logWarn } from '../../utils/logger' import { clientIp, logInfo, logWarn } from '../../utils/logger'
interface LoginBody { interface LoginBody {
username?: string username?: string
password?: string password?: string
secret?: string secret?: string
captchaId?: string
captchaPosition?: number
} }
const loginAttempts = new Map<string, { count: number, resetAt: number }>() const loginAttempts = new Map<string, { count: number, resetAt: number }>()
@@ -38,11 +42,20 @@ function checkRateLimit(ip: string): void {
} }
} }
function verifyCaptchaOrFail(event: H3Event, body: LoginBody) {
const id = body.captchaId?.trim() ?? ''
const position = Number(body.captchaPosition)
if (!id || !verifyLoginCaptcha(id, position)) {
createApiError(event, 'INVALID_REQUEST', '请先完成滑块验证', 400)
}
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const ip = clientIp(event) const ip = clientIp(event)
checkRateLimit(ip) checkRateLimit(ip)
const body = await readBody<LoginBody>(event) const body = await readBody<LoginBody>(event)
verifyCaptchaOrFail(event, body ?? {})
const initialized = isInitialized() const initialized = isInitialized()
if (!initialized) { if (!initialized) {
+45 -7
View File
@@ -3,8 +3,9 @@ import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } fr
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { MAX_DELETE_BATCH } from '../../utils/constants' import { MAX_DELETE_BATCH } from '../../utils/constants'
import { insertActivityLog } from '../../utils/db' import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key' import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
import { deleteImage, headImage } from '../../utils/storage' import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
import { logInfo } from '../../utils/logger' import { logInfo } from '../../utils/logger'
interface BatchDeleteBody { interface BatchDeleteBody {
@@ -33,7 +34,11 @@ export default defineEventHandler(async (event) => {
} }
for (const key of keys) { for (const key of keys) {
if (!validateImageKey(key)) { if (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
) {
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400) createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400)
} }
} }
@@ -43,7 +48,38 @@ export default defineEventHandler(async (event) => {
for (const key of keys) { for (const key of keys) {
try { try {
const existing = await headImage(key) const storageKey = await resolveStorageImageKey(key) ?? key
const existing = await headImage(storageKey)
const indexed = getImageIndexRow(key) ?? getImageIndexRow(storageKey)
if (!existing && indexed) {
try {
await assertImageOwnership(event, indexed.user_id)
} catch {
failed.push({
key,
error: { code: 'FORBIDDEN', message: '无权操作此图片' }
})
continue
}
deleteImageIndex(key)
if (storageKey !== key) deleteImageIndex(storageKey)
deleted.push(key)
insertActivityLog({
action: 'delete',
key,
originalName: indexed.original_name,
size: indexed.size,
contentType: indexed.content_type,
source: resolveActivitySource(event),
userId: indexed.user_id ?? null,
backendId: indexed.backend_id ?? null
})
logInfo('delete orphan index', { key })
continue
}
if (!existing) { if (!existing) {
failed.push({ failed.push({
@@ -63,7 +99,7 @@ export default defineEventHandler(async (event) => {
continue continue
} }
await deleteImage(key) await deleteImage(storageKey, key)
deleted.push(key) deleted.push(key)
insertActivityLog({ insertActivityLog({
@@ -72,10 +108,12 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName, originalName: existing.originalName,
size: existing.size, size: existing.size,
contentType: existing.contentType, contentType: existing.contentType,
source: resolveActivitySource(event) source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key, size: existing.size }) logInfo('delete success', { key: storageKey, size: existing.size })
} catch { } catch {
failed.push({ failed.push({
key, key,
+7 -4
View File
@@ -1,5 +1,6 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access' import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { isValidFolderName } from '../../utils/image-key' import { readBackendIdQuery } from '../../utils/image-query'
import { createApiError } from '../../utils/api-error'
import { countImages } from '../../utils/storage' import { countImages } from '../../utils/storage'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -7,10 +8,12 @@ export default defineEventHandler(async (event) => {
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const query = getQuery(event) const query = getQuery(event)
const raw = typeof query.folder === 'string' ? query.folder.trim() : '' const backendId = readBackendIdQuery(query)
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const total = await countImages(folder, userFilter) const total = await countImages(userFilter, backendId)
return { total } return { total }
}) })
+42 -7
View File
@@ -2,8 +2,9 @@ import type { DeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access' import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { insertActivityLog } from '../../utils/db' import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key' import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
import { deleteImage, headImage } from '../../utils/storage' import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
import { logInfo } from '../../utils/logger' import { logInfo } from '../../utils/logger'
interface DeleteBody { interface DeleteBody {
@@ -19,11 +20,43 @@ export default defineEventHandler(async (event) => {
const key = (typeof query.key === 'string' ? query.key : undefined) const key = (typeof query.key === 'string' ? query.key : undefined)
?? body?.key ?? body?.key
if (!key || !validateImageKey(key)) { if (
!key
|| (
!validateImageKey(key)
&& !toCanonicalImageKey(key)
&& !getImageIndexRow(key)
)
) {
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400) createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
} }
const existing = await headImage(key) const storageKey = await resolveStorageImageKey(key) ?? key
const existing = await headImage(storageKey)
const indexed = getImageIndexRow(key) ?? getImageIndexRow(storageKey)
if (!existing && indexed) {
await assertImageOwnership(event, indexed.user_id)
deleteImageIndex(key)
if (storageKey !== key) deleteImageIndex(storageKey)
insertActivityLog({
action: 'delete',
key,
originalName: indexed.original_name,
size: indexed.size,
contentType: indexed.content_type,
source: resolveActivitySource(event),
userId: indexed.user_id ?? null,
backendId: indexed.backend_id ?? null
})
logInfo('delete orphan index', { key })
const response: DeleteResponse = { success: true }
return response
}
if (!existing) { if (!existing) {
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404) createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
} }
@@ -31,7 +64,7 @@ export default defineEventHandler(async (event) => {
await assertImageOwnership(event, existing.userId) await assertImageOwnership(event, existing.userId)
try { try {
await deleteImage(key) await deleteImage(storageKey, key)
} catch { } catch {
createApiError(event, 'DELETE_FAILED', '删除失败', 500) createApiError(event, 'DELETE_FAILED', '删除失败', 500)
} }
@@ -42,10 +75,12 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName, originalName: existing.originalName,
size: existing.size, size: existing.size,
contentType: existing.contentType, contentType: existing.contentType,
source: resolveActivitySource(event) source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
}) })
logInfo('delete success', { key, size: existing.size }) logInfo('delete success', { key: storageKey, size: existing.size })
const response: DeleteResponse = { success: true } const response: DeleteResponse = { success: true }
return response return response
+10 -10
View File
@@ -5,17 +5,13 @@ import {
DEFAULT_LIST_LIMIT, DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT MAX_LIST_LIMIT
} from '../../utils/constants' } from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { isValidFolderName } from '../../utils/image-key' import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { readBackendIdQuery } from '../../utils/image-query'
import { listImages } from '../../utils/storage' 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) => { export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event) await requireApiOrAdminAuth(event)
@@ -31,15 +27,19 @@ export default defineEventHandler(async (event) => {
? Math.floor(pageRaw) ? Math.floor(pageRaw)
: 1 : 1
const folder = readFolderQuery(query) const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, folder, userFilter }) const listing = await listImages({ limit, page, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = listing.items.map(stored => const items = listing.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap }) mapStoredImageToItem(event, stored, { ownerMap, backendMap })
) )
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt)) items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+9 -10
View File
@@ -8,15 +8,10 @@ import {
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db' import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response' import { mapStoredImageToItem } from '../../utils/image-response'
import { isValidFolderName } from '../../utils/image-key' import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { readBackendIdQuery } from '../../utils/image-query'
import { searchImages } from '../../utils/storage' 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) => { export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event) await requireApiOrAdminAuth(event)
@@ -41,15 +36,19 @@ export default defineEventHandler(async (event) => {
? Math.floor(pageRaw) ? Math.floor(pageRaw)
: 1 : 1
const folder = readFolderQuery(query) const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event) const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, folder, userFilter }) const result = await searchImages({ query: q, limit, page, userFilter, backendId })
const user = await getCurrentUser(event) const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = result.items.map(stored => const items = result.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap }) mapStoredImageToItem(event, stored, { ownerMap, backendMap })
) )
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt)) items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+9 -47
View File
@@ -1,6 +1,6 @@
import { readMultipartFormData } from 'h3' import { readMultipartFormData } from 'h3'
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image' import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId, canUseCustomUploadFolder } from '../../utils/access' import { requireUploadAuth, verifyApiUploadToken, getUploadUserId } from '../../utils/access'
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants' import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
import { import {
detectMimeFromSignature, detectMimeFromSignature,
@@ -11,61 +11,24 @@ import {
processSingleImageUpload processSingleImageUpload
} from '../../utils/process-image-upload' } from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { import { checkUploadRateLimit } from '../../utils/rate-limit'
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) => { export default defineEventHandler(async (event) => {
await requireUploadAuth(event) await requireUploadAuth(event)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const formData = await readMultipartFormData(event) const formData = await readMultipartFormData(event)
if (!formData?.length) { if (!formData?.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400) createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
} }
const query = getQuery(event) const tokenField = formData.find(part => part.name === 'token')
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type') const formToken = tokenField?.data?.length
const folderFromQuery = typeof query.folder === 'string' ? new TextDecoder().decode(tokenField.data).trim()
? query.folder : ''
: typeof query.type === 'string' checkUploadRateLimit(event, formToken)
? query.type
: ''
const requestedFolder = normalizeFolderName(folderFromForm || folderFromQuery)
const canCustomFolder = await canUseCustomUploadFolder(event)
if (!canCustomFolder && requestedFolder && requestedFolder !== DEFAULT_FOLDER) { const source = verifyApiUploadToken(event) ? 'api' : 'web'
createApiError( const uploadUserId = await getUploadUserId(event)
event,
'FORBIDDEN',
'当前账号不支持自定义上传目录',
403
)
}
const folder = canCustomFolder ? requestedFolder : ''
if (folder && !isValidFolderName(folder)) {
createApiError(
event,
'INVALID_REQUEST',
'无效的 folder:仅允许字母数字开头,可含 - _,最长 32 位,且不能是 api/stats 等保留名',
400
)
}
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图) // 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter( const fileParts = formData.filter(
@@ -120,7 +83,6 @@ export default defineEventHandler(async (event) => {
bytes, bytes,
filename: part.filename, filename: part.filename,
source, source,
prefix: folder || DEFAULT_FOLDER,
userId: uploadUserId userId: uploadUserId
}) })
+46 -6
View File
@@ -1,17 +1,21 @@
import { requireAdminAuth } from '../../utils/access' import { requireUserAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { logException } from '../../utils/logger'
import { import {
listActivityLogs, listActivityLogs,
listUserIdUsernameMap,
summarizeActivityLogs,
type LogAction, type LogAction,
type LogSource type LogSource
} from '../../utils/db' } from '../../utils/db'
import { ensureStorageSchema } from '../../utils/storage-backends'
import { import {
DEFAULT_LIST_LIMIT, DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT MAX_LIST_LIMIT
} from '../../utils/constants' } from '../../utils/constants'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAdminAuth(event) const user = await requireUserAuth(event)
const query = getQuery(event) const query = getQuery(event)
@@ -26,7 +30,7 @@ export default defineEventHandler(async (event) => {
: undefined : undefined
const sourceRaw = typeof query.source === 'string' ? query.source : '' const sourceRaw = typeof query.source === 'string' ? query.source : ''
const source = (sourceRaw === 'web' || sourceRaw === 'api' || sourceRaw === 'twikoo') const source = (sourceRaw === 'web' || sourceRaw === 'api')
? sourceRaw as LogSource ? sourceRaw as LogSource
: undefined : undefined
@@ -34,13 +38,34 @@ export default defineEventHandler(async (event) => {
? query.folder.trim() ? query.folder.trim()
: undefined : undefined
const searchRaw = typeof query.q === 'string' ? query.q.trim() : ''
const search = searchRaw || undefined
const pageRaw = Number(query.page ?? 1) const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0 const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw) ? Math.floor(pageRaw)
: 1 : 1
let userId: number | undefined
if (user.role === 'admin') {
const userIdRaw = query.userId
if (userIdRaw !== undefined && userIdRaw !== '' && userIdRaw !== 'all') {
const parsed = Number(userIdRaw)
if (!Number.isFinite(parsed) || parsed <= 0) {
createApiError(event, 'INVALID_REQUEST', '无效的用户筛选', 400)
}
userId = Math.floor(parsed)
}
} else {
userId = user.id
}
try { try {
const result = listActivityLogs({ limit, page, action, source, folder }) ensureStorageSchema()
const result = listActivityLogs({ limit, page, action, source, folder, userId, search })
const summary = summarizeActivityLogs({ source, folder, userId, search })
const userMap = user.role === 'admin' ? listUserIdUsernameMap() : undefined
return { return {
items: result.items.map(row => ({ items: result.items.map(row => ({
id: row.id, id: row.id,
@@ -50,15 +75,30 @@ export default defineEventHandler(async (event) => {
size: row.size, size: row.size,
contentType: row.content_type, contentType: row.content_type,
source: row.source, source: row.source,
userId: row.user_id,
username: row.username,
storage: row.backend_id
? {
id: row.backend_id,
name: row.backend_name ?? row.backend_id,
type: (row.backend_type === 'local' ? 'local' : 's3') as 'local' | 's3'
}
: null,
createdAt: row.created_at createdAt: row.created_at
})), })),
total: result.total, total: result.total,
page: result.page, page: result.page,
pageSize: result.pageSize, pageSize: result.pageSize,
totalPages: result.totalPages totalPages: result.totalPages,
summary,
users: userMap
? Array.from(userMap.entries())
.map(([id, username]) => ({ id, username }))
.sort((a, b) => a.username.localeCompare(b.username))
: undefined
} }
} catch (error) { } catch (error) {
console.error('[logs] list failed', error) logException('activity logs list failed', error)
createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500) createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500)
} }
}) })
+49 -1
View File
@@ -1,9 +1,13 @@
import { requireAdminAuth } from '../../utils/access' import { requireAdminAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error' import { createApiError } from '../../utils/api-error'
import { import {
getSetting,
setSetting, setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS, SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL, SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY, SETTINGS_WEBP_QUALITY,
setAllowRegistration setAllowRegistration
} from '../../utils/db' } from '../../utils/db'
@@ -11,18 +15,24 @@ import {
getSettingsPayload, getSettingsPayload,
isValidImageBaseUrl, isValidImageBaseUrl,
isValidRefererHost, isValidRefererHost,
isValidSiteBaseUrl,
MAX_AUTO_DELETE_DAYS, MAX_AUTO_DELETE_DAYS,
normalizeImageBaseUrl, normalizeImageBaseUrl,
normalizeRefererHosts, normalizeRefererHosts,
normalizeSiteBaseUrl,
parseAutoDeleteDays, parseAutoDeleteDays,
parseWebpQuality, parseWebpQuality,
setGlobalAutoDeletePolicy setGlobalAutoDeletePolicy,
validateDomainSeparationPair
} from '../../utils/env' } from '../../utils/env'
interface SettingsPatchBody { interface SettingsPatchBody {
webpQuality?: unknown webpQuality?: unknown
allowedRefererHosts?: unknown allowedRefererHosts?: unknown
siteBaseUrl?: unknown
imageBaseUrl?: unknown imageBaseUrl?: unknown
hideFolderInUrl?: unknown
storageUseDatePath?: unknown
autoDeleteDays?: unknown autoDeleteDays?: unknown
allowRegistration?: unknown allowRegistration?: unknown
} }
@@ -61,6 +71,21 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized) setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized)
} }
if (body.siteBaseUrl !== undefined) {
const configured = normalizeSiteBaseUrl(
typeof body.siteBaseUrl === 'string' ? body.siteBaseUrl : ''
)
if (configured && !isValidSiteBaseUrl(configured)) {
createApiError(
event,
'INVALID_REQUEST',
'SITE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名',
400
)
}
setSetting(SETTINGS_SITE_BASE_URL, configured)
}
if (body.imageBaseUrl !== undefined) { if (body.imageBaseUrl !== undefined) {
const configured = normalizeImageBaseUrl( const configured = normalizeImageBaseUrl(
typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : '' typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : ''
@@ -76,6 +101,21 @@ export default defineEventHandler(async (event) => {
setSetting(SETTINGS_IMAGE_BASE_URL, configured) setSetting(SETTINGS_IMAGE_BASE_URL, configured)
} }
if (body.siteBaseUrl !== undefined || body.imageBaseUrl !== undefined) {
const siteConfigured = normalizeSiteBaseUrl(
getSetting(SETTINGS_SITE_BASE_URL) ?? ''
)
const imageConfigured = normalizeImageBaseUrl(
getSetting(SETTINGS_IMAGE_BASE_URL) ?? ''
)
if (siteConfigured && imageConfigured) {
const pairError = validateDomainSeparationPair(siteConfigured, imageConfigured)
if (pairError) {
createApiError(event, 'INVALID_REQUEST', pairError, 400)
}
}
}
if (body.autoDeleteDays !== undefined) { if (body.autoDeleteDays !== undefined) {
const days = parseAutoDeleteDays(String(body.autoDeleteDays)) const days = parseAutoDeleteDays(String(body.autoDeleteDays))
if (days === null) { if (days === null) {
@@ -93,5 +133,13 @@ export default defineEventHandler(async (event) => {
setAllowRegistration(Boolean(body.allowRegistration)) setAllowRegistration(Boolean(body.allowRegistration))
} }
if (body.hideFolderInUrl !== undefined) {
setSetting(SETTINGS_HIDE_FOLDER_IN_URL, body.hideFolderInUrl ? 'true' : 'false')
}
if (body.storageUseDatePath !== undefined) {
setSetting(SETTINGS_STORAGE_USE_DATE_PATH, body.storageUseDatePath ? 'true' : 'false')
}
return getSettingsPayload(event) return getSettingsPayload(event)
}) })
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more