12 Commits
Author SHA1 Message Date
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
O96u 7c45a78572 release: v1.0.4
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
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Add PasswordInput with show/hide toggle, align legacy setup/register
routing, merge duplicate i18n keys, and remove unused jose dependency.
2026-08-25 16:11:50 +08:00
O96u 661146ce20 docs: add Linux.do friend link and align README tables 2026-08-24 10:41:52 +08:00
104 changed files with 8513 additions and 1117 deletions

No files matched your search

+11 -1
View File
@@ -11,7 +11,17 @@
# 可选;不填可在设置页配置。若在此填写则覆盖后台设置
# 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
# 图片存储目录(默认 ./data,容器内为 /data)
+12
View File
@@ -23,6 +23,18 @@ jobs:
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- name: Install npm 11
run: npm install -g npm@11.12.1
- name: Install dependencies
run: npm ci
- name: Extract release notes from CHANGELOG
id: notes
run: |
+4
View File
@@ -36,3 +36,7 @@ data
# 本地 Git hooks 模板(仅安装在 .git/hooks/,不进仓库)
git-hooks/
# 本地维护脚本(assign-image-owner、fpk 打包等,不进仓库)
scripts/
dist-fpk/
+111 -1
View File
@@ -2,6 +2,112 @@
本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。
## [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 测试):`docs/domain-separation.md`
### 改进
- 双域名下防盗链自动放行网站域与图片域主机名
- 开发服务器默认绑定 `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
### 新增
- 密码输入框支持显示/隐藏切换(`PasswordInput` 组件,含无障碍文案)
### 改进
- 遗留 `ADMIN_SECRET` 部署:`/setup`、`/register` 路由与 `needsMigration` 检测对齐
- 合并重复的密码不一致 i18n 文案;移除未使用的 `jose` 依赖
### 修复
- 修复 `PasswordInput` 导致 CI typecheck 失败
## [1.0.3] - 2026-08-23
### 修复
@@ -53,8 +159,12 @@
### 路线图
- **v1.1.0**(计划中):S3、Cloudflare R2 及更多对象存储后端
- **v1.1.2**(当前 `main`):操作日志、图库存储筛选、上传限流
[1.1.2]: https://github.com/O96u/PicHost/releases/tag/v1.1.2
[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.2]: https://github.com/O96u/PicHost/releases/tag/v1.0.2
[1.0.1]: https://github.com/O96u/PicHost/releases/tag/v1.0.1
+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
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
Preamble
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
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>.
+115 -71
View File
@@ -6,17 +6,18 @@
<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">
<a href="https://github.com/O96u/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p>
<p align="center">
<a href="#branches">Branches</a> ·
<a href="#quick-start">Quick Start</a> ·
<a href="#screenshots">Screenshots</a> ·
<a href="#features">Features</a> ·
@@ -27,26 +28,43 @@
---
## Branches
| Branch | Notes |
| ------ | ----- |
| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.5** — local disk + multi-backend storage, bidirectional dual-domain isolation, [`/storage`](docs/README.md) UI, hybrid URLs |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively |
Use **main** for the full product and multiple cloud backends.
If you **only use Cloudflare R2**, check out the dedicated branch:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## Screenshots
| Login | Upload |
|:---:|:---:|
| ![Login](docs/screenshots/login.png) | ![Upload](docs/screenshots/upload.png) |
| Upload | Storage |
| :------------------------------------: | :--------------------------------: |
| ![Upload](docs/screenshots/upload.png) | ![Storage](docs/screenshots/storage.png) |
| Upload preferences | Stats & gallery |
|:---:|:---:|
| ![Preferences](docs/screenshots/preferences.png) | ![Stats](docs/screenshots/stats.png) |
| Settings | |
|:---:|:---:|
| ![Settings](docs/screenshots/settings.png) | |
| Stats & gallery | Settings |
| :----------------------------------: | :--------------------------------: |
| ![Stats](docs/screenshots/stats.png) | ![Settings](docs/screenshots/settings.png) |
## Features
- **Drag, click, or Ctrl+V paste** to upload; admins can use custom `folder` paths
- **Server-side WebP** (sharp), Referer hotlink protection, configurable public base URL
- **Multi-user** — username/password login; optional registration; users see only their images
- **Upload preferences** (card flip UI): client-side compression, auto-copy links, per-user auto-delete
- **Upload preferences** (home card flip): client-side compression, auto-copy links, per-user auto-delete
- **Multi-backend storage**: local disk + S3-compatible (Cloudflare R2 / Tencent COS / Alibaba OSS / AWS S3); admins manage backends at **Storage** (`/storage`)
- **Hybrid URLs**: `proxy` (same-origin via PicHost) or `public` (302 to CDN / bucket URL)
- **Gallery** — browse, search, batch delete; admin sees uploader badges
- **Stats** — upload/delete metrics, folder breakdown; admin sees registered user count
- **API** — global token (admin) + per-user tokens; in-app cURL snippets
@@ -89,47 +107,28 @@ 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).
| 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 + S3-compatible backends (R2 / COS / OSS / AWS); `images` index in SQLite |
| Deploy | Docker (amd64 / arm64), docker compose |
| Tests | Vitest |
## 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 |
| 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.
@@ -137,22 +136,22 @@ Regular users are limited to the `images/` folder; custom `folder` requires admi
```
/data
├── pichost.db # SQLite: users, sessions, settings, logs
├── images/ # Default upload folder
├── pichost.db # SQLite: users, sessions, settings, storage_backends, images index
├── images/ # Default upload folder (when using local backend)
├── blog/ # Example custom folder
└── twikoo/ # Twikoo comment images
```
Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. Back up the entire `/data` directory.
Pattern: `folder/YYYY/MM/randomId.webp` + `.meta.json`. With cloud backends, blobs live in the bucket; SQLite `images` tracks `backend_id` and `key`. Back up all of `/data` plus bucket data.
## 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 |
| 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:
@@ -167,17 +166,52 @@ 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` |
| 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
- Set `IMAGE_BASE_URL` (single domain) or `SITE_BASE_URL` + `IMAGE_BASE_URL` (dual-domain separation)
### Recommended architecture (dual domains)
**One Docker instance + two domains**, both fully proxied to `6892` (isolation via PicHost middleware):
```nginx
# admin.example.com — admin + API + images
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;
}
}
```
FN NAS [Lucky](https://github.com/gdy666/lucky) reverse proxy (public hostname → backend `http://<LAN-IP>:6892`):
| Site domain (admin) | Image domain |
| :-----------------: | :----------: |
| ![Lucky site](docs/screenshots/lucky-site.png) | ![Lucky image](docs/screenshots/lucky-image.png) |
See **[docs/domain-separation.en.md](docs/domain-separation.en.md)** for Caddy/NPM notes, env vars, and setup fields.
## Development
@@ -192,14 +226,24 @@ 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 |
| Version | Plan |
| ---------- | -------------------------------------------------------------- |
| **v1.1.5** | Gallery preview reliability: auto-retry, storage verify, no-cache errors (current `main`) |
| **v1.1.4** | Flat vs date storage paths, basename public URLs, bidirectional domain isolation |
| **v1.1.3** | Dual-domain separation, optional `images/` URL prefix, update check |
| **v1.1.2** | Activity logs, gallery storage filter, upload rate limits |
| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging |
| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI |
| **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration |
| **v1.0.3** | CI and Docker multi-arch build fixes |
| **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 |
| **v1.0.0** | Local storage, multi-user, API, Twikoo |
## License
[MIT](LICENSE)
[GPL-3.0](LICENSE)
## Friend links
[LINUX DO](https://linux.do/)
+115 -71
View File
@@ -6,17 +6,18 @@
<p align="center"><strong>个人轻量图床</strong> · 图片放好,管理也要顺手</p>
<p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 零配置引导</p>
<p align="center">自托管图床 / 多用户 / Docker / API / Twikoo · 本地磁盘或对象存储</p>
<p align="center">
<a href="https://github.com/O96u/PicHost/blob/main/package.json"><img src="https://img.shields.io/github/package-json/v/O96u/PicHost?style=flat-square&color=22c55e" alt="version" /></a>
<a href="https://github.com/O96u/PicHost/actions/workflows/ci.yml"><img src="https://github.com/O96u/PicHost/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<img src="https://img.shields.io/badge/Nuxt-4-00DC82?style=flat-square&logo=nuxt.js&logoColor=white" alt="Nuxt 4" />
<a href="https://hub.docker.com/r/muxui/pichost"><img src="https://img.shields.io/badge/Docker-muxui%2Fpichost-2496ED?style=flat-square&logo=docker&logoColor=white" alt="Docker" /></a>
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT" />
<img src="https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square" alt="GPL-3.0" />
</p>
<p align="center">
<a href="#分支">分支</a> ·
<a href="#快速开始">快速开始</a> ·
<a href="#界面预览">预览</a> ·
<a href="#特性">特性</a> ·
@@ -27,26 +28,43 @@
---
## 分支
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.5** 主线:本地磁盘 + 多对象存储、双域名双向隔离、[`/storage`](docs/README.md) 存储管理、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 |
日常使用、需要管理多种存储后端,请直接用 **main**。
若图床**只接 Cloudflare R2**、希望更简化的 R2 专用版本,可切换分支:
```bash
git clone https://github.com/O96u/PicHost.git
cd PicHost
git checkout cloudflare
```
---
## 界面预览
| 登录 | 上传 |
|:---:|:---:|
| ![登录](docs/screenshots/login.png) | ![上传](docs/screenshots/upload.png) |
| 上传 | 存储管理 |
| :-----------------------------------: | :-------------------------------------: |
| ![上传](docs/screenshots/upload.png) | ![存储](docs/screenshots/storage.png) |
| 上传偏好 | 统计与图库 |
|:---:|:---:|
| ![上传偏好](docs/screenshots/preferences.png) | ![统计](docs/screenshots/stats.png) |
| 系统设置 | |
|:---:|:---:|
| ![设置](docs/screenshots/settings.png) | |
| 统计与图库 | 系统设置 |
| :---------------------------------: | :-------------------------------------: |
| ![统计](docs/screenshots/stats.png) | ![设置](docs/screenshots/settings.png) |
## 特性
- **拖拽 / 点击 / Ctrl+V 粘贴**上传;管理员可自定义 `folder` 目录
- **服务端 WebP 压缩**(sharp)、Referer 防盗链、可配置访问域名
- **多用户**:账号密码登录;管理员可开放注册;普通用户仅见自己的图片
- **上传偏好**(卡片翻转设置):客户端预压缩、自动复制链接、按用户自动删除
- **上传偏好**(首页卡片翻转):客户端预压缩、自动复制链接、按用户自动删除
- **多后端存储**:本地磁盘 + S3 兼容(Cloudflare R2 / 腾讯云 COS / 阿里云 OSS / AWS S3);管理员在 **存储**(`/storage`)页管理,新上传写入默认后端
- **混合直链**:`proxy`(PicHost 同源代理)或 `public`(302 到 CDN / 桶公网地址)
- **图库**:浏览、搜索、批量删除;管理员可查看上传者标签
- **统计**:上传/删除趋势、目录分布;管理员额外显示注册用户数量
- **API**:全局 Token(管理员)+ 每用户个人 Token;后台可复制 cURL
@@ -94,47 +112,28 @@ 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)。
| 层级 | 技术 |
| -------- | ------------------------------------------ |
| 前端 | Nuxt 4、Vue 3、Nuxt UI 4、Tailwind CSS 4 |
| 后端 | Nitro(Node.js)、SQLite |
| 图片处理 | sharp(WebP 转码与压缩) |
| 鉴权 | Session Cookie、scrypt 密码哈希、API Token |
| 存储 | 本地磁盘 + S3 兼容后端(R2 / COS / OSS / AWS);`images` 索引在 SQLite |
| 部署 | Docker(amd64 / arm64)、docker compose |
| 测试 | Vitest |
## 用户与权限
| 场景 | 鉴权 | 图片归属 / 可见范围 |
|------|------|---------------------|
| 网页上传 | Session | 当前登录用户 |
| API 上传 + **个人 Token** | `Auth-Token` | 该 Token 所属用户 |
| API 上传 + **全局 Token** | `Auth-Token` | 管理员(`userId` 为空) |
| Twikoo `/api/index.php` | 表单 `token` | 与全局 Token 相同 |
| 图库列表 / 搜索 / 删除 | Session 或 Token | 普通用户仅自己;管理员全部 |
| 图片直链 `GET /images/...` | 无(Referer 防盗链) | 知道 URL 即可访问 |
| 场景 | 鉴权 | 图片归属 / 可见范围 |
| -------------------------- | -------------------- | -------------------------- |
| 网页上传 | 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 可用。
@@ -142,22 +141,22 @@ npm run dev
```
/data
├── pichost.db # SQLite:用户、会话、设置、操作日志
├── images/ # 默认上传目录
├── pichost.db # SQLite:用户、会话、设置、storage_backends、images 索引
├── images/ # 默认上传目录(本地后端时)
├── blog/ # 管理员自定义 folder 示例
└── twikoo/ # Twikoo 评论图片
```
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。备份整个 `/data` 即可。
路径规则:`目录/年/月/随机ID.webp` + 同名 `.meta.json`。云存储时文件在对应桶内,SQLite `images` 表记录 `backend_id` 与 `key`。备份请包含整个 `/data` 与云桶数据。
## API 概览
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/images/upload` | 上传图片 |
| GET | `/api/images` | 分页列表 |
| DELETE | `/api/images` | 删除单张(`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
| 方法 | 路径 | 说明 |
| ------ | -------------------- | ---------------------- |
| POST | `/api/images/upload` | 上传图片 |
| GET | `/api/images` | 分页列表 |
| DELETE | `/api/images` | 删除单张(`key`) |
| POST | `/api/index.php` | Twikoo / EasyImage 2.0 |
上传示例:
@@ -172,17 +171,52 @@ curl -X POST "https://pic.example.com/api/images/upload" \
## Twikoo
| 配置项 | 值 |
|--------|-----|
| `IMAGE_CDN` | `easyimage` |
| `IMAGE_CDN_URL` | `https://你的域名/api/index.php` |
| `IMAGE_CDN_TOKEN` | 与 `API_UPLOAD_TOKEN` 相同 |
| 配置项 | 值 |
| ----------------- | -------------------------------- |
| `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`
- 公网访问请配置 `IMAGE_BASE_URL`(单域名)或 `SITE_BASE_URL` + `IMAGE_BASE_URL`(双域名分离)
### 推荐部署架构(双域名分离)
**单 Docker 实例 + 两个域名** 均可全量反代到 `6892`(隔离由 PicHost 中间件负责):
```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;
}
}
```
飞牛 NAS [Lucky](https://github.com/gdy666/lucky) 反向代理示例(前端域名 → 后端 `http://<内网IP>:6892`):
| 网站域名(管理后台) | 图片域名 |
| :------------------: | :------: |
| ![Lucky 网站域名](docs/screenshots/lucky-site.png) | ![Lucky 图片域名](docs/screenshots/lucky-image.png) |
完整说明(Caddy / NPM、环境变量、初始化填写项)见 **[docs/domain-separation.md](docs/domain-separation.md)**。
## 开发
@@ -197,14 +231,24 @@ npm test # 单元测试
## 路线图
| 版本 | 计划 |
|------|------|
| **v1.0.3** | CI 与 Docker 多架构构建修复(当前) |
| 版本 | 说明 |
| ---------- | ------------------------------------------------- |
| **v1.1.5** | 图库预览可靠性:自动重试、出图前校验、错误响应禁缓存(当前 main) |
| **v1.1.4** | 存储路径扁平/分组、basename 外链、双域名双向隔离 |
| **v1.1.3** | 双域名分离、隐藏 `images/` 前缀、版本更新提示 |
| **v1.1.2** | 操作日志、图库存储筛选、上传限流 |
| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强 |
| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 |
| **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 |
| **v1.0.3** | CI 与 Docker 多架构构建修复 |
| **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复 |
| **v1.0.0** | 本地存储、多用户、API、Twikoo |
| **v1.1.0** | S3、Cloudflare R2 等对象存储后端 |
| **v1.0.1** | 设置页版本展示、移动端上传偏好布局修复 |
| **v1.0.0** | 本地存储、多用户、API、Twikoo |
## License
[MIT](LICENSE)
[GPL-3.0](LICENSE)
## 友链
[LINUX DO](https://linux.do/)
+2 -15
View File
@@ -2,10 +2,6 @@
import logoLight from '~/assets/image/logo-light.png'
import logoDark from '~/assets/image/logo-dark.png'
const emit = defineEmits<{
success: []
}>()
const router = useRouter()
const { login, fetchStatus, authStatus } = useAuth()
const toast = useToast()
@@ -47,7 +43,6 @@ async function submit() {
username.value = ''
password.value = ''
secret.value = ''
emit('success')
toast.add({ title: t('auth.loginSuccess'), color: 'success' })
} else {
toast.add({
@@ -108,15 +103,11 @@ async function submit() {
>
{{ t('auth.adminSecret') }}
</label>
<UInput
<PasswordInput
id="admin-secret"
v-model="secret"
type="password"
:placeholder="t('auth.adminSecretPlaceholder')"
autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
@keyup.enter="submit"
/>
<p class="text-xs leading-relaxed text-muted">
@@ -151,15 +142,11 @@ async function submit() {
>
{{ t('auth.password') }}
</label>
<UInput
<PasswordInput
id="login-password"
v-model="password"
type="password"
:placeholder="t('auth.password')"
autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
@keyup.enter="submit"
/>
</div>
+18 -22
View File
@@ -16,44 +16,40 @@ const navItems = computed(() => {
{ label: t('nav.stats'), to: '/stats', icon: 'i-lucide-chart-column' }
]
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' })
}
return items
})
const accountMenuItems = computed<DropdownMenuItem[]>(() => {
const items: DropdownMenuItem[] = [
{
label: t('nav.changePassword'),
icon: 'i-lucide-key-round',
to: '/account/password'
}
]
if (isAdmin.value) {
items.push({
label: t('nav.systemSettings'),
icon: 'i-lucide-settings',
to: '/settings'
})
}
items.push({
const accountMenuItems = computed<DropdownMenuItem[]>(() => [
{
label: t('nav.logs'),
icon: 'i-lucide-scroll-text',
to: '/logs'
},
{
label: t('nav.changePassword'),
icon: 'i-lucide-key-round',
to: '/account/password'
},
{
label: t('nav.logout'),
icon: 'i-lucide-log-out',
onSelect() {
void handleLogout()
}
})
return items
})
}
])
const desktopUserMenuItems = computed<DropdownMenuItem[][]>(() => [
accountMenuItems.value
])
function isNavActive(to: string) {
if (to === '/settings') {
return route.path === '/settings'
}
return route.path.startsWith(to)
}
+66 -3
View File
@@ -9,10 +9,12 @@ const props = withDefaults(defineProps<{
deleting?: boolean
selectable?: boolean
showKey?: boolean
showStorage?: boolean
compact?: boolean
}>(), {
selectable: true,
showKey: true,
showStorage: false,
compact: false
})
@@ -24,8 +26,48 @@ const emit = defineEmits<{
const { formatFileSize } = useFileSize()
const { t, locale } = useI18n()
const imageError = ref(false)
const retryCount = ref(0)
const copyFormat = ref<CopyFormat>('url')
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(() => [
{ label: t('copy.url'), value: 'url' as const },
{ label: t('copy.markdown'), value: 'markdown' as const },
@@ -62,6 +104,10 @@ const copySuccessTitle = computed(() => {
}
})
const storageIcon = computed(() =>
props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud'
)
function toggleSelected(value: boolean | 'indeterminate') {
emit('update:selected', value === true)
}
@@ -72,11 +118,12 @@ function toggleSelected(value: boolean | 'indeterminate') {
<div class="relative aspect-square shrink-0 overflow-hidden bg-muted">
<img
v-if="!imageError"
:src="image.url"
:key="`${image.key}-${retryCount}`"
:src="previewSrc"
:alt="image.originalName"
loading="lazy"
class="absolute inset-0 size-full object-cover object-center"
@error="imageError = true"
@error="onPreviewError"
>
<div
v-else
@@ -106,7 +153,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
</div>
<div
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
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 +166,22 @@ function toggleSelected(value: boolean | 'indeterminate') {
<span class="truncate">{{ image.owner.username }}</span>
</span>
</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
+3
View File
@@ -7,12 +7,14 @@ const props = withDefaults(defineProps<{
deletingKeys: Set<string>
selectable?: boolean
showKey?: boolean
showStorage?: boolean
emptyText?: string
dense?: boolean
compact?: boolean
}>(), {
selectable: true,
showKey: true,
showStorage: false,
dense: false,
compact: false
})
@@ -48,6 +50,7 @@ function updateSelection(key: string, selected: boolean) {
:deleting="deletingKeys.has(image.key)"
:selectable="selectable"
:show-key="showKey"
:show-storage="showStorage"
:compact="compact"
@update:selected="updateSelection(image.key, $event)"
@delete="emit('delete', image.key)"
+19 -20
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
const open = defineModel<boolean>('open', { default: false })
const { logout, isAdmin } = useAuth()
const { logout } = useAuth()
const { t, locale } = useI18n()
const colorMode = useColorMode()
const {
@@ -136,6 +136,24 @@ function toggleOpen() {
{{ t('nav.account') }}
</h3>
<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
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"
@@ -154,25 +172,6 @@ function toggleOpen() {
/>
</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
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"
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
const model = defineModel<string>({ default: '' })
defineProps<{
id?: string
placeholder?: string
autocomplete?: string
}>()
const show = ref(false)
const { t } = useI18n()
</script>
<template>
<UInput
:id="id"
v-model="model"
:type="show ? 'text' : 'password'"
:placeholder="placeholder"
:autocomplete="autocomplete"
size="lg"
class="w-full"
:ui="{ root: 'w-full', trailing: 'pe-1' }"
v-bind="$attrs"
>
<template #trailing>
<UButton
color="neutral"
variant="link"
size="sm"
:icon="show ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="show ? t('auth.hidePassword') : t('auth.showPassword')"
:aria-pressed="show"
:aria-controls="id"
type="button"
@click="() => { show = !show }"
/>
</template>
</UInput>
</template>
<style scoped>
:deep(::-ms-reveal) {
display: none;
}
</style>
@@ -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>
+22 -4
View File
@@ -71,8 +71,10 @@ export function useAuth() {
const status = useState<AuthStatus>('auth-status', () => 'checking')
const user = useState<AuthUser | null>('auth-user', () => null)
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
/** 是否已完成至少一次会话校验(用于避免路由切换时反复全屏 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 isAdmin = computed(() => user.value?.role === 'admin')
@@ -84,10 +86,14 @@ export function useAuth() {
return data
}
async function checkSession() {
status.value = 'checking'
async function checkSession(options?: { force?: boolean }) {
const silent = !options?.force && hasValidatedOnce.value
if (!silent) {
status.value = 'checking'
}
try {
const data = await fetchStatus()
hasValidatedOnce.value = true
if (data.user) {
user.value = data.user
status.value = 'authenticated'
@@ -97,6 +103,7 @@ export function useAuth() {
status.value = 'unauthenticated'
return false
} catch {
hasValidatedOnce.value = true
user.value = null
status.value = 'unauthenticated'
return false
@@ -129,6 +136,7 @@ export function useAuth() {
user.value = result.user
}
status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
@@ -159,6 +167,7 @@ export function useAuth() {
)
user.value = result.user
status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
@@ -173,6 +182,9 @@ export function useAuth() {
username: string
password: string
allowRegistration: boolean
domainSeparation?: boolean
siteBaseUrl?: string
imageBaseUrl?: string
}): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password)
if (validationError) {
@@ -188,12 +200,16 @@ export function useAuth() {
body: {
username: input.username.trim(),
password: input.password,
allowRegistration: input.allowRegistration
allowRegistration: input.allowRegistration,
domainSeparation: input.domainSeparation,
siteBaseUrl: input.siteBaseUrl,
imageBaseUrl: input.imageBaseUrl
}
}
)
user.value = result.user
status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
@@ -229,6 +245,7 @@ export function useAuth() {
)
user.value = result.user
status.value = 'authenticated'
hasValidatedOnce.value = true
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
@@ -271,6 +288,7 @@ export function useAuth() {
status.value = 'unauthenticated'
user.value = null
authStatus.value = null
hasValidatedOnce.value = true
}
}
+52 -7
View File
@@ -1,10 +1,55 @@
export function useFileSize() {
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
export const QUOTA_UNITS = ['KB', 'MB', 'GB', 'TB'] as const
export type QuotaUnit = typeof QUOTA_UNITS[number]
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-'
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 }
}
+86 -6
View File
@@ -17,6 +17,8 @@ export function useImageList() {
const searchQuery = 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> {
const folder = activeFolder.value.trim()
@@ -24,18 +26,53 @@ export function useImageList() {
return { folder }
}
function storageQueryParams(): Record<string, string> {
const backendId = activeStorageBackend.value.trim()
if (!backendId || backendId === 'all') return {}
return { backendId }
}
function listQueryParams(): Record<string, string> {
return {
...folderQueryParams(),
...storageQueryParams()
}
}
function matchesActiveFolder(key: string) {
const folder = activeFolder.value.trim()
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() {
loadingTotal.value = true
try {
const data = await $fetch<{ total: number }>('/api/images/count', {
credentials: 'include',
query: folderQueryParams()
query: listQueryParams()
})
folderTotal.value = data.total
} catch {
@@ -53,7 +90,7 @@ export function useImageList() {
query: {
limit: PAGE_SIZE,
page: targetPage,
...folderQueryParams()
...listQueryParams()
}
})
@@ -81,7 +118,7 @@ export function useImageList() {
q,
limit: PAGE_SIZE,
page: targetPage,
...folderQueryParams()
...listQueryParams()
}
})
@@ -107,7 +144,7 @@ export function useImageList() {
page.value = 1
activeSearch.value = ''
searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal()])
await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()])
}
async function setActiveFolder(folder: string) {
@@ -122,6 +159,18 @@ export function useImageList() {
}
}
async function setActiveStorageBackend(backendId: string) {
const next = backendId.trim() || 'all'
if (next === activeStorageBackend.value) return
activeStorageBackend.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function submitSearch() {
const q = searchQuery.value.trim()
activeSearch.value = q
@@ -143,7 +192,9 @@ export function useImageList() {
}
function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => matchesActiveFolder(item.key))
const matched = newItems.filter(item =>
matchesActiveFolder(item.key) && matchesActiveStorage(item)
)
if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value]
total.value += matched.length
@@ -164,16 +215,41 @@ export function useImageList() {
}
}
const activeStorageLabel = computed(() => {
if (activeStorageBackend.value === 'all') return ''
return storageBackendOptions.value.find(
backend => backend.id === activeStorageBackend.value
)?.name ?? activeStorageBackend.value
})
const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? t('stats.allFolders') : folder
const storageLabel = activeStorageLabel.value
if (loadingTotal.value && folderTotal.value === null) {
return `${folderLabel} · ${t('stats.counting')}`
return storageLabel
? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}`
: `${folderLabel} · ${t('stats.counting')}`
}
if (activeSearch.value) {
if (storageLabel) {
return t('stats.summarySearchStorage', {
folder: folderLabel,
storage: storageLabel,
q: activeSearch.value,
total: total.value
})
}
return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value })
}
const count = folderTotal.value === null ? total.value : folderTotal.value
if (storageLabel) {
return t('stats.summaryTotalStorage', {
folder: folderLabel,
storage: storageLabel,
total: count
})
}
return t('stats.summaryTotal', { folder: folderLabel, total: count })
})
@@ -184,6 +260,8 @@ export function useImageList() {
total,
loading,
activeFolder,
activeStorageBackend,
storageBackendOptions,
searchQuery,
activeSearch,
listSummary,
@@ -193,6 +271,8 @@ export function useImageList() {
refreshList,
initializeList,
setActiveFolder,
setActiveStorageBackend,
loadStorageBackendOptions,
submitSearch,
goToPage,
prependItems,
+4 -16
View File
@@ -38,7 +38,7 @@ async function submit() {
if (!canSubmit.value || loading.value) return
if (newPassword.value !== confirmPassword.value) {
toast.add({ title: t('password.mismatch'), color: 'error' })
toast.add({ title: t('auth.passwordMismatch'), color: 'error' })
return
}
@@ -101,14 +101,10 @@ async function submit() {
>
{{ t('password.current') }}
</label>
<UInput
<PasswordInput
id="current-password"
v-model="currentPassword"
type="password"
autocomplete="current-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
@@ -119,15 +115,11 @@ async function submit() {
>
{{ t('password.new') }}
</label>
<UInput
<PasswordInput
id="new-password"
v-model="newPassword"
type="password"
:placeholder="t('password.newPlaceholder')"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
<PasswordStrength :password="newPassword" />
</div>
@@ -139,14 +131,10 @@ async function submit() {
>
{{ t('password.confirmNew') }}
</label>
<UInput
<PasswordInput
id="confirm-new-password"
v-model="confirmPassword"
type="password"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
+10 -4
View File
@@ -6,6 +6,7 @@ interface SettingsResponse {
env: {
webpQuality: number
refererConfigured: boolean
siteBaseUrl: string
imageBaseUrl: string
appVersion: string
}
@@ -32,6 +33,8 @@ const regenerateResultOpen = ref(false)
const newToken = ref('')
const baseUrl = computed(() => {
const configured = settings.value?.env.siteBaseUrl
if (configured) return configured
if (import.meta.client) {
return window.location.origin
}
@@ -57,7 +60,7 @@ const apiDocs = computed<ApiDocItem[]>(() => {
${authHeaderFlag.value} \\
-F "image=@./demo.png"
# 上传到自定义目录 blog/
${t('api.docs.upload.curlCustomFolder')}
curl -X POST "${baseUrl.value}/api/images/upload" \\
${authHeaderFlag.value} \\
-F "folder=blog" \\
@@ -73,7 +76,7 @@ curl -X POST "${baseUrl.value}/api/images/upload" \\
? `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
${authHeaderFlag.value}
# 按目录筛选
${t('api.docs.list.curlFolderFilter')}
curl "${baseUrl.value}/api/images?folder=blog&limit=20&page=1" \\
${authHeaderFlag.value}`
: `curl "${baseUrl.value}/api/images?limit=20&page=1" \\
@@ -240,10 +243,13 @@ onMounted(async () => {
return
}
await checkSession()
if (isAuthenticated.value) {
await loadSettings()
}
})
watch(isAuthenticated, async (authed) => {
if (authed) {
watch(isAuthenticated, async (authed, prev) => {
if (authed && prev === false) {
await nextTick()
await loadSettings()
} else {
+621
View File
@@ -0,0 +1,621 @@
<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 },
{ label: t('logs.sourceTwikoo'), value: 'twikoo' 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')
case 'twikoo':
return t('logs.sourceTwikoo')
}
}
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>
+3 -11
View File
@@ -22,7 +22,7 @@ const canSubmit = computed(() =>
onMounted(async () => {
const status = await fetchStatus()
if (!status.initialized) {
if (!status.initialized && !status.legacyMode) {
await router.replace('/setup')
return
}
@@ -118,15 +118,11 @@ async function submit() {
>
{{ t('auth.password') }}
</label>
<UInput
<PasswordInput
id="register-password"
v-model="password"
type="password"
:placeholder="t('setup.passwordPlaceholder')"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
<PasswordStrength :password="password" />
</div>
@@ -138,15 +134,11 @@ async function submit() {
>
{{ t('auth.confirmPassword') }}
</label>
<UInput
<PasswordInput
id="register-confirm"
v-model="confirmPassword"
type="password"
:placeholder="t('setup.confirmPlaceholder')"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
-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>
+687
View File
@@ -0,0 +1,687 @@
<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 { 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 siteBaseUrlDraft = ref('')
const imageBaseUrlDraft = ref('')
const hideFolderInUrlDraft = ref(false)
const storageUseDatePathDraft = ref(true)
const allowRegistrationDraft = ref(false)
const domainSeparationDraft = ref(false)
const hideFolderConfirmOpen = ref(false)
interface ReleaseCheckResponse {
currentVersion: string
latestVersion: string | null
updateAvailable: boolean
releaseUrl: string | null
}
const releaseCheck = ref<ReleaseCheckResponse | null>(null)
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
|| 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 onHideFolderDraftChange(next: boolean | 'indeterminate') {
if (next !== true) {
hideFolderInUrlDraft.value = false
return
}
if (folderOptions.value.length > 1) {
hideFolderConfirmOpen.value = true
return
}
hideFolderInUrlDraft.value = true
}
function confirmHideFolderEnable() {
hideFolderInUrlDraft.value = true
hideFolderConfirmOpen.value = false
}
function cancelHideFolderEnable() {
hideFolderInUrlDraft.value = false
hideFolderConfirmOpen.value = false
}
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 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 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
}
loadPreferences()
await Promise.all([loadSettings(), loadFolders(), 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="onHideFolderDraftChange"
/>
<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="settings-side-panel flex h-full min-w-0 flex-col rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<div class="shrink-0 space-y-1.5">
<label class="text-sm font-medium">{{ t('settings.defaultFolder') }}</label>
<p class="text-xs leading-relaxed text-muted">
{{ t('settings.defaultFolderHint') }}
</p>
<USelect
v-model="defaultFolder"
:items="folderSelectItems"
class="settings-folder-select w-40 max-w-full"
/>
</div>
<div class="settings-referer-section mt-4 space-y-1.5 border-t border-default/60 pt-4">
<div class="flex flex-wrap items-center gap-2">
<label class="text-sm font-medium">{{ t('settings.accessControl') }}</label>
<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 flex min-h-0 w-full max-w-xl flex-1 flex-col font-mono text-sm"
/>
</div>
</div>
</div>
<div class="rounded-xl border border-default/60 bg-muted/10 p-4 sm:p-5">
<h3 class="text-sm font-semibold">
{{ t('settings.domainSettings') }}
</h3>
<div class="mt-4 space-y-4">
<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="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md"
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 max-w-xl 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 max-w-xl 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 max-w-xl 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>
</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>
<UModal
:open="hideFolderConfirmOpen"
:title="t('settings.hideFolderConfirmTitle')"
:description="t('settings.hideFolderConfirmDesc')"
@update:open="(open) => { if (!open) cancelHideFolderEnable() }"
>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
:label="t('common.cancel')"
color="neutral"
variant="outline"
@click="cancelHideFolderEnable"
/>
<UButton
:label="t('settings.hideFolderConfirmEnable')"
color="warning"
@click="confirmHideFolderEnable"
/>
</div>
</template>
</UModal>
</div>
</template>
<style scoped>
.settings-side-panel {
overflow: hidden;
}
.settings-folder-select {
min-width: 0;
}
.settings-folder-select :deep(button),
.settings-folder-select :deep([role='combobox']) {
justify-content: space-between;
}
.settings-referer-section {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.settings-referer-field {
flex: 1;
min-height: 0;
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) {
height: 100%;
min-height: 5.5rem;
resize: none;
}
</style>
+82 -19
View File
@@ -9,25 +9,42 @@ const { setup, migrate, fetchStatus } = useAuth()
const toast = useToast()
const { t } = useI18n()
const isMigrate = computed(() => route.query.migrate === '1')
const needsMigration = ref(false)
const isMigrate = computed(() => needsMigration.value || route.query.migrate === '1')
const username = ref('')
const password = ref('')
const confirmPassword = ref('')
const allowRegistration = ref(false)
const domainSeparation = ref(false)
const siteBaseUrl = ref('')
const imageBaseUrl = ref('')
const loading = ref(false)
const canSubmit = computed(() =>
Boolean(username.value && password.value && confirmPassword.value)
&& password.value === confirmPassword.value
&& isPasswordValid(password.value)
&& AUTH_USERNAME_PATTERN.test(username.value.trim())
)
const canSubmit = computed(() => {
if (!username.value || !password.value || !confirmPassword.value) return false
if (password.value !== confirmPassword.value) return false
if (!isPasswordValid(password.value)) return false
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 () => {
const status = await fetchStatus()
needsMigration.value = status.needsMigration
if (status.legacyMode && !status.needsMigration) {
await router.replace('/')
return
}
if (status.initialized && !status.needsMigration) {
await router.replace('/')
return
}
if (import.meta.client) {
siteBaseUrl.value = window.location.origin
}
})
@@ -44,7 +61,10 @@ async function submit() {
const payload = {
username: username.value.trim(),
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
? await migrate(payload)
@@ -70,7 +90,7 @@ async function submit() {
<div class="absolute top-4 right-4">
<AuthPagePreferences />
</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="mx-auto inline-flex items-center justify-center">
<img
@@ -129,15 +149,11 @@ async function submit() {
>
{{ t('auth.password') }}
</label>
<UInput
<PasswordInput
id="setup-password"
v-model="password"
type="password"
:placeholder="t('setup.passwordPlaceholder')"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
<PasswordStrength :password="password" />
</div>
@@ -149,15 +165,11 @@ async function submit() {
>
{{ t('auth.confirmPassword') }}
</label>
<UInput
<PasswordInput
id="setup-confirm"
v-model="confirmPassword"
type="password"
:placeholder="t('setup.confirmPlaceholder')"
autocomplete="new-password"
size="lg"
class="w-full"
:ui="{ root: 'w-full' }"
/>
</div>
@@ -166,6 +178,57 @@ async function submit() {
{{ t('setup.allowRegistration') }}
</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="https://github.com/O96u/PicHost/blob/main/docs/domain-separation.md"
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
type="submit"
:label="isMigrate ? t('setup.migrateSubmit') : t('setup.initSubmit')"
+42 -3
View File
@@ -41,7 +41,11 @@ const {
setActiveFolder,
submitSearch,
goToPage,
removeItems
removeItems,
activeStorageBackend,
storageBackendOptions,
setActiveStorageBackend,
loadStorageBackendOptions
} = useImageList()
const { formatFileSize } = useFileSize()
@@ -60,6 +64,7 @@ const deleteTargetKeys = ref<string[]>([])
const batchDeleting = ref(false)
const showScrollTop = ref(false)
const currentFolder = ref('all')
const currentStorage = ref('all')
const folderOptions = ref<string[]>(['images'])
const folderSelectItems = computed(() => [
@@ -67,6 +72,14 @@ const folderSelectItems = computed(() => [
...folderOptions.value.map(folder => ({ label: folder, value: folder }))
])
const storageSelectItems = computed(() => [
{ label: t('stats.filterStorageAll'), value: 'all' },
...storageBackendOptions.value.map(backend => ({
label: backend.name,
value: backend.id
}))
])
const selectedCount = computed(() => selectedKeys.value.size)
const FOLDER_CHART_COLORS = [
@@ -165,7 +178,8 @@ async function refreshGallery() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
try {
await Promise.all([refreshList(), loadFolders()])
await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) {
handleAuthError(error)
}
@@ -188,8 +202,10 @@ async function refreshAll() {
await Promise.all([
fetchStats(),
reloadGallery(),
loadFolders()
loadFolders(),
loadStorageBackendOptions()
])
currentStorage.value = activeStorageBackend.value
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
@@ -211,6 +227,17 @@ async function handleFolderChange(folder: string) {
}
}
async function handleStorageChange(backendId: string) {
if (!isAuthenticated.value) return
currentStorage.value = backendId
selectedKeys.value = new Set()
try {
await setActiveStorageBackend(backendId)
} catch (error: unknown) {
handleAuthError(error)
}
}
async function handleSearch() {
if (!isAuthenticated.value) return
selectedKeys.value = new Set()
@@ -316,6 +343,9 @@ onMounted(async () => {
return
}
await checkSession()
if (isAuthenticated.value) {
await refreshAll()
}
})
onUnmounted(() => {
@@ -478,6 +508,14 @@ watch(isAuthenticated, async (authed, prev) => {
:aria-label="t('stats.folder')"
@update:model-value="handleFolderChange"
/>
<USelect
v-model="currentStorage"
:items="storageSelectItems"
class="w-[6.5rem] shrink-0 sm:w-36"
size="sm"
:aria-label="t('stats.storage')"
@update:model-value="handleStorageChange"
/>
<UInput
v-model="searchQuery"
icon="i-lucide-search"
@@ -539,6 +577,7 @@ watch(isAuthenticated, async (authed, prev) => {
<ImageGrid
v-else
:show-key="false"
show-storage
:items="items"
:selected-keys="selectedKeys"
: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
username: string
}
/** 图片所在存储后端(统计/管理列表) */
storage?: {
id: string
name: string
type: 'local' | 's3'
}
}
export interface UploadErrorItem {
+43
View File
@@ -0,0 +1,43 @@
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
export interface ActivityLogUser {
id: number
username: string
}
export interface ActivityLogStorage {
id: string
name: string
type: 'local' | 's3'
}
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 ActivityLogSummary {
total: number
upload: number
delete: number
}
export interface ActivityLogListResponse {
items: ActivityLogItem[]
total: number
page: number
pageSize: number
totalPages: number
summary: ActivityLogSummary
users?: ActivityLogUser[]
}
+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
}
+62
View File
@@ -0,0 +1,62 @@
# PicHost 文档资源
本目录主要存放 README 引用的界面截图与分支说明。
## 分支
| 分支 | 说明 |
| ---- | ---- |
| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.0** 主线:本地磁盘 + 多对象存储(R2 / COS / OSS / AWS)、`/storage` 管理页、混合直链 |
| [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署场景,配置更聚焦 R2 |
- 需要多种云存储或完整管理界面 → 使用 **main**
- 图床只接 **Cloudflare R2** → 可选用 **`cloudflare`** 分支
## 截图一览
以下为 **main(v1.1.0)** 界面截图。
| 文件 | 说明 |
| ---- | ---- |
| `screenshots/upload.png` | 首页上传区(顶栏含 API / 统计 / 存储 / 设置) |
| `screenshots/storage.png` | 存储管理(`/storage`):多后端卡片、用量、设默认 |
| `screenshots/stats.png` | 统计与图库 |
| `screenshots/settings.png` | 系统设置(上传偏好 + 系统设置) |
| `screenshots/preferences.png` | 首页上传卡片翻转后的上传偏好 |
| `screenshots/login.png` | 登录页 |
| `screenshots/lucky-site.png` | 飞牛 NAS Lucky:网站域名反代示例 |
| `screenshots/lucky-image.png` | 飞牛 NAS Lucky:图片域名反代示例 |
## 部署文档
| 文档 | 说明 |
| ---- | ---- |
| [domain-separation.md](domain-separation.md) | 双域名分离推荐架构(Nginx / Lucky / 环境变量) |
| [domain-separation.en.md](domain-separation.en.md) | Dual-domain deployment (English) |
## 相关页面路由(main / v1.1.0)
| 路由 | 权限 | 说明 |
| ---- | ---- | ---- |
| `/` | 登录用户 | 上传与图库入口 |
| `/storage` | 管理员 | 存储后端管理 |
| `/settings` | 管理员 | 系统设置 |
| `/stats` | 登录用户 | 统计 |
| `/api` | 登录用户 | API 文档与 Token |
管理员顶栏:**API** · **统计** · **存储** · **设置**。用户下拉菜单仅 **修改密码**、**退出登录**。
## Cloudflare R2 配置对照
**main**:后台 **存储 → 添加存储 → Cloudflare R2** 预设。
**cloudflare 分支**(R2 专用):通常以环境变量为主:
| 配置项 | 值 |
| ------ | -- |
| `STORAGE_BACKEND` | `s3` |
| `S3_ENDPOINT` | `https://<account_id>.r2.cloudflarestorage.com` |
| `S3_REGION` | `auto` |
| `S3_BUCKET` | 桶名 |
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | R2 API Token |
| 直链 | 默认 PicHost 代理;桶绑域名后可改公开 URL |
+83
View File
@@ -0,0 +1,83 @@
# 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 + images
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;
}
}
```
> Caddy, NPM, and similar tools work the same: proxy both hostnames to `http://<host>:6892` and forward `Host` and `X-Forwarded-Proto`.
## 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` |
![Lucky site domain](screenshots/lucky-site.png)
**Image domain**
| Field | Example |
| ----- | ------- |
| Service type | Reverse proxy |
| Frontend | `image.pichost.com` |
| Backend | `http://192.168.8.3:6892` |
![Lucky image domain](screenshots/lucky-image.png)
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
```
## Notes
- Hostnames must **differ** (same machine, different subdomains is fine)
- 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
+129
View File
@@ -0,0 +1,129 @@
# 双域名分离 · 推荐部署架构
启用「后台与图片域名分离」后,管理后台、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;
}
}
```
> Caddy、NPM(Nginx Proxy Manager)等工具同理:两个域名均反代到 `http://<主机>:6892`,并保留 `Host` 与 `X-Forwarded-Proto` 头。
## 飞牛 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` |
![Lucky 网站域名配置](screenshots/lucky-site.png)
**图片域名**
| 项 | 示例值 |
| -- | ------ |
| 服务类型 | 反向代理 |
| 前端地址 | `image.pichost.com` |
| 后端地址 | `http://192.168.8.3:6892` |
![Lucky 图片域名配置](screenshots/lucky-image.png)
两条规则的后端地址相同即可;域名分离与路径隔离由 PicHost 根据请求 `Host` 自动处理。
## 环境变量(可选)
除 Web 设置外,也可在 `.env` 或 Docker 环境中配置:
```env
SITE_BASE_URL=https://admin.example.com
IMAGE_BASE_URL=https://pic.example.com
```
## 本地测试(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/` | 能打开,但与双域名配置无关,登录态不共享 |
上传一张图后,复制链接应指向 `pic.pichost.test` 域名;在后台图库中缩略图也应能正常显示。
## 相关说明
- 两个域名 **主机名不能相同**(可同机、不同子域)
- 图片域无需单独部署静态目录;与 EasyImages 等不同,PicHost 在应用内完成隔离
- 单域名部署可不启用分离,仅配置 `IMAGE_BASE_URL` 或使用默认当前访问域名
- 配置了 Referer 防盗链时,网站域与图片域会自动加入白名单,无需手写两个域名
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+158 -9
View File
@@ -10,6 +10,7 @@
"cancel": "Cancel",
"copy": "Copy",
"delete": "Delete",
"edit": "Edit",
"search": "Search",
"refresh": "Refresh",
"back": "Back",
@@ -17,12 +18,15 @@
"notConfigured": "(not configured)",
"countImages": "{count} images",
"itemsUnit": "items",
"imagesUnit": "images"
"imagesUnit": "images",
"more": "More"
},
"nav": {
"api": "API",
"stats": "Stats",
"logs": "Activity log",
"settings": "Settings",
"storage": "Storage",
"menu": "Menu",
"language": "Language",
"appearance": "Appearance",
@@ -64,6 +68,8 @@
"setupFailed": "Setup failed. Check your input",
"migrateFailed": "Migration failed. Check your input",
"passwordMismatch": "Passwords do not match",
"showPassword": "Show password",
"hidePassword": "Hide password",
"registerSuccess": "Registered successfully",
"errors": {
"invalidUsername": "Username must be 3–32 letters, numbers, or underscores",
@@ -77,6 +83,17 @@
"passwordPlaceholder": "Set login password",
"confirmPlaceholder": "Enter password again",
"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, API docs, and Twikoo 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",
"initSubmit": "Create and continue",
"migrateSuccess": "Migration complete",
@@ -93,7 +110,6 @@
"new": "New password",
"confirmNew": "Confirm new password",
"newPlaceholder": "Set new password",
"mismatch": "New passwords do not match",
"changed": "Password updated",
"tooShort": "New password must be at least 8 characters",
"changeFailed": "Failed to change password",
@@ -164,6 +180,7 @@
"title": "System settings",
"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",
@@ -171,10 +188,26 @@
"accessControl": "Access control",
"refererHint": "Referer hotlink protection; comma-separated hostnames, empty = no restriction",
"refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "Link generation",
"imageBaseUrlHint": "Public URL prefix for generated links; use your public domain behind a reverse proxy",
"linkGeneration": "Domains & links",
"domainSettings": "Domain settings",
"storagePathSettings": "Storage paths & direct links",
"domainSeparationActive": "Domain separation enabled: image host serves images only; site host serves admin and API",
"siteBaseUrl": "Site URL",
"siteBaseUrlHint": "No trailing slash; admin UI, API docs, and Twikoo 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",
"imageBaseUrlActive": "Active: {url}",
"hideFolderInUrl": "Hide folder in image URLs",
"hideFolderInUrlHint": "Links become /file.webp without folder or date paths; legacy full paths still work",
"hideFolderConfirmTitle": "Hide folder prefix in URLs?",
"hideFolderConfirmDesc": "If two folders share the same filename (e.g. images/a.webp and blog/a.webp), the short URL /a.webp resolves to the images folder first—you may get the wrong file. Enable only with a single upload folder or globally unique filenames.",
"hideFolderConfirmEnable": "Enable anyway",
"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",
"badgeSaved": "Saved",
"badgeDefault": "Default",
@@ -182,7 +215,9 @@
"tokenEnv": "Environment",
"tokenDb": "Generated in app",
"tokenNone": "Not configured",
"apiTokenLine": "API Token · {source} · v{version}",
"apiTokenLine": "API Token · {source} ·",
"viewReleases": "View releases",
"updateAvailable": "Update available: {version}",
"unsavedChanges": "Unsaved changes",
"manageToken": "Manage token",
"envOverride": "Overridden by environment variable; cannot edit in UI",
@@ -190,6 +225,86 @@
"saveFailed": "Failed to save 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": {
"title": "Statistics",
"subtitle": "Overview and gallery",
@@ -208,6 +323,8 @@
"folderCount": "{count} · {size}",
"gallery": "Gallery",
"folder": "Folder",
"filterStorageAll": "All storage",
"storage": "Storage",
"searchPlaceholder": "Search filename",
"clear": "Clear",
"selected": "{n} selected",
@@ -222,12 +339,42 @@
"allFolders": "All",
"counting": "Counting…",
"summarySearch": "{folder} · Search “{q}” · {total} images",
"summaryTotal": "{folder} · {total} images"
"summaryTotal": "{folder} · {total} images",
"summarySearchStorage": "{folder} · {storage} · Search “{q}” · {total} images",
"summaryTotalStorage": "{folder} · {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",
"sourceTwikoo": "Twikoo",
"colTime": "Time",
"colUser": "User",
"colStorage": "Storage",
"colAction": "Action",
"colSource": "Source",
"colFile": "File",
"colFolder": "Folder",
"colSize": "Size",
"colKey": "Path"
},
"image": {
"empty": "No images",
"previewFailed": "Preview failed",
"uploader": "Uploader: {name}"
"uploader": "Uploader: {name}",
"storageBackend": "Storage: {name}"
},
"delete": {
"confirmTitle": "Confirm delete",
@@ -272,12 +419,14 @@
"upload": {
"title": "Upload images",
"descAdmin": "Upload one or more images. Use image field; folder creates directory if missing. Also accepts file, files, type.",
"descUser": "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/.",
"curlCustomFolder": "# Upload to custom folder blog/"
},
"list": {
"title": "List images",
"descAdmin": "Paginated gallery; optional folder filter. limit default 20, max 100.",
"descUser": "Paginated list of your images. limit default 20, max 100."
"descUser": "Paginated list of your images. limit default 20, max 100.",
"curlFolderFilter": "# Filter by folder"
},
"search": {
"title": "Search images",
+158 -9
View File
@@ -10,6 +10,7 @@
"cancel": "取消",
"copy": "复制",
"delete": "删除",
"edit": "编辑",
"search": "搜索",
"refresh": "刷新",
"back": "返回",
@@ -17,12 +18,15 @@
"notConfigured": "(未配置)",
"countImages": "{count} 张",
"itemsUnit": "条",
"imagesUnit": "张"
"imagesUnit": "张",
"more": "更多"
},
"nav": {
"api": "API",
"stats": "统计",
"logs": "操作日志",
"settings": "设置",
"storage": "存储",
"menu": "菜单",
"language": "语言",
"appearance": "外观",
@@ -64,6 +68,8 @@
"setupFailed": "初始化失败,请检查输入",
"migrateFailed": "迁移失败,请检查输入",
"passwordMismatch": "两次输入的密码不一致",
"showPassword": "显示密码",
"hidePassword": "隐藏密码",
"registerSuccess": "注册成功",
"errors": {
"invalidUsername": "用户名需为 3–32 位字母、数字或下划线",
@@ -77,6 +83,17 @@
"passwordPlaceholder": "设置登录密码",
"confirmPlaceholder": "再次输入密码",
"allowRegistration": "允许其他用户注册",
"domainSeparation": "启用后台与图片域名分离",
"domainSeparationHint": "管理后台与 API 使用网站域名,图片直链使用独立图片域名",
"domainSeparationProxyHint": "需将两个域名解析到本机,并通过 Nginx、Caddy、NPM 等反代到端口 6892",
"domainSeparationProxyExample": "查看示例",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"domainSeparationInvalid": "请填写有效的网站域名与图片域名,且两者不能相同",
"migrateSubmit": "完成迁移",
"initSubmit": "创建并进入",
"migrateSuccess": "账号迁移完成",
@@ -93,7 +110,6 @@
"new": "新密码",
"confirmNew": "确认新密码",
"newPlaceholder": "设置新密码",
"mismatch": "两次输入的新密码不一致",
"changed": "密码已修改",
"tooShort": "新密码至少 8 位",
"changeFailed": "修改密码失败",
@@ -164,6 +180,7 @@
"title": "系统设置",
"subtitle": "保存后写入数据库;环境变量优先级更高",
"uploadAccount": "上传与账号",
"featureToggles": "功能开关",
"defaultFolder": "默认上传目录",
"defaultFolderHint": "保存在本机浏览器,仅影响您的上传目标目录",
"allowRegistration": "开放用户注册",
@@ -171,10 +188,26 @@
"accessControl": "访问控制",
"refererHint": "Referer 防盗链,逗号分隔允许引用的域名,留空不限制",
"refererPlaceholder": "example.com, www.example.com",
"linkGeneration": "链接生成",
"imageBaseUrlHint": "生成图片链接的 URL 前缀,内网访问建议填公网域名",
"linkGeneration": "域名与链接",
"domainSettings": "域名配置",
"storagePathSettings": "存储路径与直链",
"domainSeparationActive": "已启用域名分离:图片域仅出图,管理域提供后台与 API",
"siteBaseUrl": "网站域名",
"siteBaseUrlHint": "末尾不加 /;管理后台、API 文档与 Twikoo 上传地址",
"siteBaseUrlPlaceholder": "https://admin.example.com",
"siteBaseUrlActive": "当前生效:{url}",
"imageBaseUrl": "图片域名",
"imageBaseUrlHint": "末尾不加 /;生成图片直链地址",
"imageBaseUrlSingleHint": "末尾不加 /;留空则使用当前访问域名;单域名部署无需填写网站域名",
"imageBaseUrlPlaceholder": "https://pic.example.com",
"imageBaseUrlActive": "当前生效:{url}",
"hideFolderInUrl": "隐藏直链目录前缀",
"hideFolderInUrlHint": "开启后直链为 /xxx.webp,不含 images/ 或日期路径;旧链接仍可用",
"hideFolderConfirmTitle": "确认隐藏目录前缀?",
"hideFolderConfirmDesc": "开启后不同目录下若存在相同文件名(如 images/a.webp 与 blog/a.webp),短链接 /a.webp 将优先解析到 images 目录,可能访问到非预期的图片。仅单一上传目录或文件名全局唯一时建议开启。",
"hideFolderConfirmEnable": "仍要开启",
"storageUseDatePath": "上传目录按年/月分组",
"storageUseDatePathHint": "开启后保存为 images/2026/08/xxx.webp;关闭则为 images/xxx.webp。仅影响启用后的新上传",
"badgeEnv": ".env",
"badgeSaved": "已保存",
"badgeDefault": "默认",
@@ -182,7 +215,9 @@
"tokenEnv": "环境变量",
"tokenDb": "后台生成",
"tokenNone": "未配置",
"apiTokenLine": "API Token · {source} · v{version}",
"apiTokenLine": "API Token · {source} ·",
"viewReleases": "查看发行版",
"updateAvailable": "有新版本 {version}",
"unsavedChanges": "有未保存的更改",
"manageToken": "管理 Token",
"envOverride": "该项已由环境变量覆盖,无法在后台修改",
@@ -190,6 +225,86 @@
"saveFailed": "保存服务器配置失败",
"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": {
"title": "统计",
"subtitle": "数据概览与图库记录",
@@ -208,6 +323,8 @@
"folderCount": "{count} 张 · {size}",
"gallery": "图库记录",
"folder": "目录",
"filterStorageAll": "全部存储",
"storage": "存储",
"searchPlaceholder": "搜索文件名",
"clear": "清除",
"selected": "已选 {n}",
@@ -222,12 +339,42 @@
"allFolders": "全部",
"counting": "统计中…",
"summarySearch": "{folder} · 搜索「{q}」· 共 {total} 张",
"summaryTotal": "{folder} · 共 {total} 张"
"summaryTotal": "{folder} · 共 {total} 张",
"summarySearchStorage": "{folder} · {storage} · 搜索「{q}」· 共 {total} 张",
"summaryTotalStorage": "{folder} · {storage} · 共 {total} 张"
},
"logs": {
"pageTitle": "操作日志",
"pageSubtitle": "查看您的上传与删除记录",
"pageSubtitleAdmin": "查看所有用户的上传与删除记录",
"loadFailed": "加载操作日志失败",
"empty": "暂无操作记录",
"entriesUnit": "条记录",
"filterActionAll": "全部操作",
"filterSourceAll": "全部来源",
"filterUserAll": "全部用户",
"searchPlaceholder": "搜索文件名",
"summaryAll": "全部 · 共 {total} 条记录,上传 {upload} 条,删除 {delete} 条",
"actionUpload": "上传",
"actionDelete": "删除",
"sourceWeb": "网页",
"sourceApi": "API",
"sourceTwikoo": "Twikoo",
"colTime": "时间",
"colUser": "用户",
"colStorage": "存储",
"colAction": "操作",
"colSource": "来源",
"colFile": "文件名",
"colFolder": "目录",
"colSize": "大小",
"colKey": "路径"
},
"image": {
"empty": "暂无图片",
"previewFailed": "预览失败",
"uploader": "上传者:{name}"
"uploader": "上传者:{name}",
"storageBackend": "存储:{name}"
},
"delete": {
"confirmTitle": "确认删除",
@@ -272,12 +419,14 @@
"upload": {
"title": "上传图片",
"descAdmin": "上传单张或多张图片。字段 image 传图片,folder 指定目录(不存在则自动创建);仍兼容 file、files、type。",
"descUser": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。"
"descUser": "上传单张或多张图片。字段 image 传图片;仍兼容 file、files。图片固定存入 images/ 目录。",
"curlCustomFolder": "# 上传到自定义目录 blog/"
},
"list": {
"title": "获取图片列表",
"descAdmin": "分页列出图库,folder 可筛选目录,limit 默认 20、最大 100。",
"descUser": "分页列出您的图库,limit 默认 20、最大 100。"
"descUser": "分页列出您的图库,limit 默认 20、最大 100。",
"curlFolderFilter": "# 按目录筛选"
},
"search": {
"title": "搜索图片",
+12
View File
@@ -30,10 +30,16 @@ export default defineNuxtConfig({
appVersion: pkg.version,
devBypassAccess: process.env.DEV_BYPASS_ACCESS === 'true',
adminSecret: '',
siteBaseUrl: '',
imageBaseUrl: '',
apiUploadToken: ''
},
devServer: {
host: '127.0.0.1',
port: 3000
},
compatibilityDate: '2026-06-30',
nitro: {
@@ -43,6 +49,12 @@ export default defineNuxtConfig({
}
},
vite: {
server: {
allowedHosts: true
}
},
eslint: {
config: {
stylistic: {
+399 -13
View File
@@ -1,19 +1,19 @@
{
"name": "pichost",
"version": "1.0.3",
"version": "1.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pichost",
"version": "1.0.3",
"version": "1.1.5",
"hasInstallScript": true,
"license": "MIT",
"license": "GPL-3.0-only",
"dependencies": {
"@aws-sdk/client-s3": "^3.876.0",
"@iconify-json/lucide": "^1.2.114",
"@nuxt/ui": "^4.9.0",
"@nuxtjs/i18n": "^10.6.0",
"jose": "^6.0.11",
"nuxt": "^4.4.8",
"sharp": "^0.35.3",
"tailwindcss": "^4.3.1"
@@ -71,6 +71,314 @@
"@types/json-schema": "^7.0.15"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.29",
"resolved": "https://registry.npmmirror.com/@aws-sdk/checksums/-/checksums-3.1000.29.tgz",
"integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1118.0",
"resolved": "https://registry.npmmirror.com/@aws-sdk/client-s3/-/client-s3-3.1118.0.tgz",
"integrity": "sha512-nh46pLKeRNFvTOZAJMfYR7kZ/KzhSqzhXVIJSwl4jJ/b2D+KOXL2jeOCryOvGgwpUQo9YNWFUg63cm2qr5MX9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.29",
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/credential-provider-node": "^3.972.81",
"@aws-sdk/middleware-sdk-s3": "^3.972.75",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.977.9",
"resolved": "https://registry.npmmirror.com/@aws-sdk/core/-/core-3.977.9.tgz",
"integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.5",
"@aws-sdk/xml-builder": "^3.972.40",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.33.3",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.17.2",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.70",
"resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz",
"integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.72",
"resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz",
"integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.15",
"resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz",
"integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/credential-provider-env": "^3.972.70",
"@aws-sdk/credential-provider-http": "^3.972.72",
"@aws-sdk/credential-provider-login": "^3.972.77",
"@aws-sdk/credential-provider-process": "^3.972.70",
"@aws-sdk/credential-provider-sso": "^3.973.14",
"@aws-sdk/credential-provider-web-identity": "^3.972.76",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.77",
"resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz",
"integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.81",
"resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.81.tgz",
"integrity": "sha512-Rml+WitoFvXmv6JZ18U/xGdGDGGvB/mOin0ya0lTnTrdC0Z1lrVxTYh7iNklZBcvcRMrs4DoEf6xy1KWyrLQQw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.70",
"@aws-sdk/credential-provider-http": "^3.972.72",
"@aws-sdk/credential-provider-ini": "^3.973.15",
"@aws-sdk/credential-provider-process": "^3.972.70",
"@aws-sdk/credential-provider-sso": "^3.973.14",
"@aws-sdk/credential-provider-web-identity": "^3.972.76",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/credential-provider-imds": "^4.4.16",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.70",
"resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz",
"integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.14",
"resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz",
"integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/token-providers": "3.1116.0",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.76",
"resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz",
"integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.75",
"resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz",
"integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.44",
"resolved": "https://registry.npmmirror.com/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz",
"integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.46",
"resolved": "https://registry.npmmirror.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz",
"integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.5",
"@smithy/signature-v4": "^5.6.12",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1116.0",
"resolved": "https://registry.npmmirror.com/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz",
"integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.977.9",
"@aws-sdk/nested-clients": "^3.997.44",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.5",
"resolved": "https://registry.npmmirror.com/@aws-sdk/types/-/types-3.974.5.tgz",
"integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.40",
"resolved": "https://registry.npmmirror.com/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz",
"integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -6509,6 +6817,87 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@smithy/core": {
"version": "3.33.3",
"resolved": "https://registry.npmmirror.com/@smithy/core/-/core-3.33.3.tgz",
"integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmmirror.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.7.2",
"resolved": "https://registry.npmmirror.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz",
"integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.11.3",
"resolved": "https://registry.npmmirror.com/@smithy/node-http-handler/-/node-http-handler-4.11.3.tgz",
"integrity": "sha512-2jY1tSpERfPfWqyBV2pH+iGFaghVsIJszJNsT7hxtQYhVJpWDyc0LqOWI+nXOxOAHaEfZ4PXXtp1wW1TGpHhkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.3",
"resolved": "https://registry.npmmirror.com/@smithy/signature-v4/-/signature-v4-5.7.3.tgz",
"integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.17.2",
"resolved": "https://registry.npmmirror.com/@smithy/types/-/types-4.17.2.tgz",
"integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@speed-highlight/core": {
"version": "1.2.17",
"resolved": "https://registry.npmmirror.com/@speed-highlight/core/-/core-1.2.17.tgz",
@@ -9264,6 +9653,12 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmmirror.com/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.7.tgz",
@@ -12242,15 +12637,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "pichost",
"version": "1.0.3",
"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.",
"version": "1.1.5",
"description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.",
"homepage": "https://github.com/O96u/PicHost#readme",
"repository": {
"type": "git",
@@ -19,7 +19,7 @@
"twikoo",
"easyimage"
],
"license": "MIT",
"license": "GPL-3.0-only",
"private": true,
"type": "module",
"packageManager": "npm@11.12.1",
@@ -36,10 +36,10 @@
"reset-password": "node server/cli/reset-password.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.876.0",
"@iconify-json/lucide": "^1.2.114",
"@nuxt/ui": "^4.9.0",
"@nuxtjs/i18n": "^10.6.0",
"jose": "^6.0.11",
"nuxt": "^4.4.8",
"sharp": "^0.35.3",
"tailwindcss": "^4.3.1"
+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 }
})
+3 -1
View File
@@ -72,7 +72,9 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event)
source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
})
logInfo('delete success', { key, size: existing.size })
+7 -1
View File
@@ -1,5 +1,7 @@
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'
export default defineEventHandler(async (event) => {
@@ -9,8 +11,12 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event)
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const total = await countImages(folder, userFilter)
const total = await countImages(folder, userFilter, backendId)
return { total }
})
+3 -1
View File
@@ -42,7 +42,9 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event)
source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
})
logInfo('delete success', { key, size: existing.size })
+10 -2
View File
@@ -5,9 +5,12 @@ import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { listImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
@@ -32,14 +35,19 @@ export default defineEventHandler(async (event) => {
: 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, folder, userFilter })
const listing = await listImages({ limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = listing.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap })
mapStoredImageToItem(event, stored, { ownerMap, backendMap })
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+9 -2
View File
@@ -8,7 +8,9 @@ import {
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { searchImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
@@ -42,14 +44,19 @@ export default defineEventHandler(async (event) => {
: 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, folder, userFilter })
const result = await searchImages({ query: q, limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = result.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap })
mapStoredImageToItem(event, stored, { ownerMap, backendMap })
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+10 -3
View File
@@ -16,6 +16,7 @@ import {
isValidFolderName,
normalizeFolderName
} from '../../utils/image-key'
import { checkUploadRateLimit } from '../../utils/rate-limit'
function readFormText(
formData: { name?: string, data?: Buffer | Uint8Array }[],
@@ -29,14 +30,20 @@ function readFormText(
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const formData = await readMultipartFormData(event)
if (!formData?.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
const tokenField = formData.find(part => part.name === 'token')
const formToken = tokenField?.data?.length
? new TextDecoder().decode(tokenField.data).trim()
: ''
checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const query = getQuery(event)
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
const folderFromQuery = typeof query.folder === 'string'
+45 -5
View File
@@ -1,17 +1,21 @@
import { requireAdminAuth } from '../../utils/access'
import { requireUserAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { logException } from '../../utils/logger'
import {
listActivityLogs,
listUserIdUsernameMap,
summarizeActivityLogs,
type LogAction,
type LogSource
} from '../../utils/db'
import { ensureStorageSchema } from '../../utils/storage-backends'
import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireUserAuth(event)
const query = getQuery(event)
@@ -34,13 +38,34 @@ export default defineEventHandler(async (event) => {
? query.folder.trim()
: undefined
const searchRaw = typeof query.q === 'string' ? query.q.trim() : ''
const search = searchRaw || undefined
const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw)
: 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 {
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 {
items: result.items.map(row => ({
id: row.id,
@@ -50,15 +75,30 @@ export default defineEventHandler(async (event) => {
size: row.size,
contentType: row.content_type,
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
})),
total: result.total,
page: result.page,
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) {
console.error('[logs] list failed', error)
logException('activity logs list failed', error)
createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500)
}
})
+49 -1
View File
@@ -1,9 +1,13 @@
import { requireAdminAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
getSetting,
setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
setAllowRegistration
} from '../../utils/db'
@@ -11,18 +15,24 @@ import {
getSettingsPayload,
isValidImageBaseUrl,
isValidRefererHost,
isValidSiteBaseUrl,
MAX_AUTO_DELETE_DAYS,
normalizeImageBaseUrl,
normalizeRefererHosts,
normalizeSiteBaseUrl,
parseAutoDeleteDays,
parseWebpQuality,
setGlobalAutoDeletePolicy
setGlobalAutoDeletePolicy,
validateDomainSeparationPair
} from '../../utils/env'
interface SettingsPatchBody {
webpQuality?: unknown
allowedRefererHosts?: unknown
siteBaseUrl?: unknown
imageBaseUrl?: unknown
hideFolderInUrl?: unknown
storageUseDatePath?: unknown
autoDeleteDays?: unknown
allowRegistration?: unknown
}
@@ -61,6 +71,21 @@ export default defineEventHandler(async (event) => {
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) {
const configured = normalizeImageBaseUrl(
typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : ''
@@ -76,6 +101,21 @@ export default defineEventHandler(async (event) => {
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) {
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
if (days === null) {
@@ -93,5 +133,13 @@ export default defineEventHandler(async (event) => {
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)
})
+21 -1
View File
@@ -4,13 +4,21 @@ import {
createUserAccount,
isInitialized
} from '../utils/auth'
import { setAllowRegistration } from '../utils/db'
import { setAllowRegistration, setSetting, SETTINGS_IMAGE_BASE_URL, SETTINGS_SITE_BASE_URL } from '../utils/db'
import {
normalizeImageBaseUrl,
normalizeSiteBaseUrl,
validateDomainSeparationPair
} from '../utils/env'
import { clientIp, logInfo } from '../utils/logger'
interface SetupBody {
username?: string
password?: string
allowRegistration?: boolean
domainSeparation?: boolean
siteBaseUrl?: string
imageBaseUrl?: string
}
export default defineEventHandler(async (event) => {
@@ -30,6 +38,18 @@ export default defineEventHandler(async (event) => {
})
setAllowRegistration(Boolean(body?.allowRegistration))
if (body?.domainSeparation) {
const siteBaseUrl = normalizeSiteBaseUrl(body.siteBaseUrl ?? '')
const imageBaseUrl = normalizeImageBaseUrl(body.imageBaseUrl ?? '')
const pairError = validateDomainSeparationPair(siteBaseUrl, imageBaseUrl)
if (pairError) {
createApiError(event, 'INVALID_REQUEST', pairError, 400)
}
setSetting(SETTINGS_SITE_BASE_URL, siteBaseUrl)
setSetting(SETTINGS_IMAGE_BASE_URL, imageBaseUrl)
}
await createSession(event, user.id)
logInfo('setup complete', { ip: clientIp(event), username: user.username })
+2 -1
View File
@@ -1,5 +1,6 @@
import { getImageUserFilter, requireUserAuth } from '../utils/access'
import { createApiError } from '../utils/api-error'
import { logException } from '../utils/logger'
import { countUsers, getActivityStats } from '../utils/db'
import {
countImages,
@@ -46,7 +47,7 @@ export default defineEventHandler(async (event) => {
byFolder: scoped.byFolder
}
} catch (error) {
console.error('[stats] failed', error)
logException('stats failed', error)
createApiError(event, 'INVALID_REQUEST', '读取统计失败', 500)
}
})
+30
View File
@@ -0,0 +1,30 @@
import { requireUserAuth } from '../utils/access'
import {
ensureStorageSchema,
listStorageBackendNameMap,
LOCAL_BACKEND_ID
} from '../utils/storage-backends'
function sortBackendsForFilter<T extends { id: string, name: string }>(backends: T[]): T[] {
return [...backends].sort((a, b) => {
if (a.id === LOCAL_BACKEND_ID) return -1
if (b.id === LOCAL_BACKEND_ID) return 1
return a.name.localeCompare(b.name, 'zh-CN')
})
}
export default defineEventHandler(async (event) => {
await requireUserAuth(event)
ensureStorageSchema()
const map = listStorageBackendNameMap()
const backends = sortBackendsForFilter(
Array.from(map.entries()).map(([id, backend]) => ({
id,
name: backend.name,
type: backend.type
}))
)
return { backends }
})
+3 -1
View File
@@ -1,6 +1,6 @@
import { requireUserAuth } from '../../utils/access'
import { ensureUserApiToken } from '../../utils/db'
import { getImageBaseUrl } from '../../utils/env'
import { getImageBaseUrl, getSiteBaseUrl, isHideFolderInUrl } from '../../utils/env'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
@@ -13,7 +13,9 @@ export default defineEventHandler(async (event) => {
env: {
webpQuality: 80,
refererConfigured: false,
siteBaseUrl: getSiteBaseUrl(event),
imageBaseUrl: getImageBaseUrl(event),
hideFolderInUrl: isHideFolderInUrl(event),
appVersion: config.appVersion as string
}
}
+29
View File
@@ -0,0 +1,29 @@
import { requireAdminAuth } from '../../utils/access'
import {
fetchLatestGitHubRelease,
isVersionNewer
} from '../../utils/app-version'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const config = useRuntimeConfig(event)
const currentVersion = String(config.appVersion ?? '')
const latest = await fetchLatestGitHubRelease()
if (!latest) {
return {
currentVersion,
latestVersion: null,
updateAvailable: false,
releaseUrl: null
}
}
return {
currentVersion,
latestVersion: latest.latestVersion,
updateAvailable: isVersionNewer(latest.latestVersion, currentVersion),
releaseUrl: latest.releaseUrl
}
})
+10
View File
@@ -0,0 +1,10 @@
import { shouldBlockImageHostRequest, shouldBlockSiteHostImageRequest } from '../utils/host-isolation'
const HOST_ISOLATION_CACHE_CONTROL = 'private, no-cache, max-age=0'
export default defineEventHandler((event) => {
if (shouldBlockImageHostRequest(event) || shouldBlockSiteHostImageRequest(event)) {
setHeader(event, 'Cache-Control', HOST_ISOLATION_CACHE_CONTROL)
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
})
+4 -2
View File
@@ -1,14 +1,16 @@
import { requestPathToImageKey, serveImageByKey } from '../utils/serve-image'
/**
* 仅当路径像图片 key({type}/YYYY/MM/file.ext)时才接管。
* 仅当路径像图片 key 或短链时才接管。
* 其它请求(/_nuxt、/api、页面)原样放行。
*/
export default defineEventHandler(async (event) => {
const method = getMethod(event)
if (method !== 'GET' && method !== 'HEAD') return
const key = requestPathToImageKey(getRequestURL(event).pathname)
const key = requestPathToImageKey(getRequestURL(event).pathname, {
hideFolder: isHideFolderInUrl(event)
})
if (!key) return
return serveImageByKey(event, key)
+43
View File
@@ -0,0 +1,43 @@
import { clientIp, logError, logException, logWarn, serializeError } from '../utils/logger'
/**
* 记录未捕获的 H3/Nitro 错误(含 createApiError 抛出的 4xx/5xx)。
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (error, { event }) => {
const serialized = serializeError(error)
const status = typeof serialized.statusCode === 'number'
? serialized.statusCode
: 500
if (!event) {
logError('unhandled error', serialized)
return
}
const path = getRequestURL(event).pathname
const method = getMethod(event)
const base = {
method,
path,
status,
ip: clientIp(event),
message: serialized.message,
...(serialized.statusMessage ? { reason: serialized.statusMessage } : {}),
...(serialized.data ? { data: serialized.data } : {})
}
if (!path.startsWith('/api/')) {
if (status >= 500) {
logException(`${method} ${path}`, error, base)
}
return
}
if (status >= 500) {
logException('API error', error, base)
} else if (status >= 400) {
logWarn('API rejected', base)
}
})
})
+16 -3
View File
@@ -1,4 +1,4 @@
import { clientIp, logInfo } from '../utils/logger'
import { clientIp, logError, logInfo, logWarn } from '../utils/logger'
/**
* 只记录 /api/*,避免 /images、/twikoo 直链刷屏。
@@ -19,11 +19,24 @@ export default defineNitroPlugin((nitroApp) => {
const status = getResponseStatus(event)
const started = event.context._reqStartedAt as number | undefined
const ms = started ? Date.now() - started : undefined
const reason = status >= 400 ? getResponseStatusText(event) : undefined
logInfo(`${method} ${path}`, {
const detail: Record<string, unknown> = {
status,
ms,
ip: clientIp(event)
})
}
if (reason && reason !== String(status)) {
detail.reason = reason
}
const line = `${method} ${path}`
if (status >= 500) {
logError(line, detail)
} else if (status >= 400) {
logWarn(line, detail)
} else {
logInfo(line, detail)
}
})
})
+16 -2
View File
@@ -1,6 +1,8 @@
import { getDataDir } from '../utils/storage'
import { getDataDir } from '../utils/data-dir'
import { runAutoDeleteCleanup } from '../utils/auto-delete'
import { logInfo } from '../utils/logger'
import { migrateLocalImagesToIndex } from '../utils/image-index'
import { ensureDefaultBackends } from '../utils/storage-backends'
import { logException, logInfo } from '../utils/logger'
export default defineNitroPlugin(() => {
const port = process.env.NITRO_PORT || process.env.PORT || '6892'
@@ -9,12 +11,24 @@ export default defineNitroPlugin(() => {
dataDir: getDataDir(),
nodeEnv: process.env.NODE_ENV || 'development',
imageBaseUrl: process.env.IMAGE_BASE_URL || '(request origin)',
storageBackend: process.env.STORAGE_BACKEND || 'db',
logLevel: process.env.LOG_LEVEL || 'info'
})
ensureDefaultBackends()
void migrateLocalImagesToIndex().then((count) => {
if (count > 0) {
logInfo('migrated local images to index', { count })
}
}).catch((error) => {
logException('migrate local images to index failed', error)
})
void runAutoDeleteCleanup().then((result) => {
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
logInfo('startup auto-delete', { ...result })
}
}).catch((error) => {
logException('startup auto-delete failed', error)
})
})
+2
View File
@@ -1,5 +1,6 @@
import { readMultipartFormData } from 'h3'
import { verifyUploadTokenValue } from '../../utils/access'
import { checkUploadRateLimit } from '../../utils/rate-limit'
import {
detectMimeFromSignature,
isAllowedMimeType
@@ -34,6 +35,7 @@ export default defineEventHandler(async (event) => {
if (!verifyUploadTokenValue(event, token)) {
return easyImageError(401, 'token 错误或无效')
}
checkUploadRateLimit(event, token)
}
const imagePart = formData.find(
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { isVersionNewer, normalizeVersionTag, parseVersionParts } from './app-version'
describe('app-version', () => {
it('normalizes tag prefixes', () => {
expect(normalizeVersionTag('v1.1.2')).toBe('1.1.2')
expect(normalizeVersionTag('V2.0.0')).toBe('2.0.0')
})
it('parses dotted versions', () => {
expect(parseVersionParts('1.10.3')).toEqual([1, 10, 3])
})
it('detects newer versions', () => {
expect(isVersionNewer('1.2.0', '1.1.2')).toBe(true)
expect(isVersionNewer('1.1.2', '1.1.2')).toBe(false)
expect(isVersionNewer('1.1.1', '1.1.2')).toBe(false)
expect(isVersionNewer('2.0.0', '1.9.9')).toBe(true)
})
})
+77
View File
@@ -0,0 +1,77 @@
const GITHUB_REPO = 'O96u/PicHost'
const RELEASES_LATEST_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`
const CACHE_TTL_MS = 60 * 60 * 1000
export interface LatestReleaseInfo {
latestVersion: string
releaseUrl: string
}
let cachedRelease: { fetchedAt: number, value: LatestReleaseInfo | null } | null = null
export function normalizeVersionTag(tag: string): string {
return tag.trim().replace(/^v/i, '')
}
export function parseVersionParts(version: string): number[] {
return normalizeVersionTag(version)
.split('.')
.map(part => Number.parseInt(part, 10))
.map(part => (Number.isFinite(part) ? part : 0))
}
export function isVersionNewer(candidate: string, current: string): boolean {
const a = parseVersionParts(candidate)
const b = parseVersionParts(current)
const length = Math.max(a.length, b.length)
for (let i = 0; i < length; i++) {
const diff = (a[i] ?? 0) - (b[i] ?? 0)
if (diff !== 0) return diff > 0
}
return false
}
export async function fetchLatestGitHubRelease(): Promise<LatestReleaseInfo | null> {
const now = Date.now()
if (cachedRelease && now - cachedRelease.fetchedAt < CACHE_TTL_MS) {
return cachedRelease.value
}
try {
const response = await fetch(RELEASES_LATEST_URL, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'PicHost'
},
signal: AbortSignal.timeout(8000)
})
if (!response.ok) {
cachedRelease = { fetchedAt: now, value: null }
return null
}
const data = await response.json() as {
tag_name?: string
html_url?: string
}
const tag = typeof data.tag_name === 'string' ? data.tag_name : ''
const releaseUrl = typeof data.html_url === 'string' ? data.html_url : ''
if (!tag) {
cachedRelease = { fetchedAt: now, value: null }
return null
}
const value: LatestReleaseInfo = {
latestVersion: normalizeVersionTag(tag),
releaseUrl: releaseUrl || `https://github.com/${GITHUB_REPO}/releases/latest`
}
cachedRelease = { fetchedAt: now, value }
return value
} catch {
cachedRelease = { fetchedAt: now, value: null }
return null
}
}
+11 -11
View File
@@ -1,7 +1,8 @@
import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type AutoDeletePolicy } from './db'
import { getGlobalAutoDeletePolicy } from './env'
import { deleteImage, headImage, listImageKeys, type StoredImage } from './storage'
import { logInfo, logWarn } from './logger'
import { deleteImage, type StoredImage } from './storage'
import { listImageKeysForAutoDelete } from './image-index'
import { logInfo, logWarn, serializeError } from './logger'
export interface AutoDeleteResult {
deleted: number
@@ -73,31 +74,30 @@ export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
}
const nowMs = Date.now()
const keys = await listImageKeys()
const images = await listImageKeysForAutoDelete()
let deleted = 0
let failed = 0
for (const key of keys) {
const image = await headImage(key)
if (!image) continue
for (const image of images) {
if (!shouldAutoDeleteImage(image, globalPolicy, adminUserIds, userPolicies, nowMs)) {
continue
}
try {
await deleteImage(key)
await deleteImage(image.key)
insertActivityLog({
action: 'delete',
key,
key: image.key,
originalName: image.originalName,
size: image.size,
contentType: image.contentType,
source: 'web'
source: 'web',
userId: image.userId ?? null,
backendId: image.backendId ?? null
})
deleted++
} catch (error) {
logWarn('auto-delete failed', { key, error })
logWarn('auto-delete failed', { key: image.key, error: serializeError(error) })
failed++
}
}
+5
View File
@@ -0,0 +1,5 @@
import { join } from 'node:path'
export function getDataDir(): string {
return process.env.DATA_DIR || join(process.cwd(), 'data')
}
+128 -25
View File
@@ -1,7 +1,8 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { getDataDir } from './storage'
import { getDataDir } from './data-dir'
import { logException } from './logger'
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
@@ -14,6 +15,11 @@ export interface ActivityLogRow {
size: number
content_type: string
source: LogSource
user_id: number | null
username: string | null
backend_id: string | null
backend_name: string | null
backend_type: string | null
created_at: string
}
@@ -24,12 +30,14 @@ export interface ActivityLogInput {
size: number
contentType: string
source: LogSource
userId?: number | null
backendId?: string | null
createdAt?: string
}
let db: DatabaseSync | null = null
function getDb(): DatabaseSync {
export function getDb(): DatabaseSync {
if (db) return db
const dataDir = getDataDir()
@@ -117,6 +125,23 @@ function migrateSchema(database: DatabaseSync): void {
VALUES ('auto_delete_enabled_at', ?, ?)
`).run(now, now)
}
const logColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!logColumns.some(column => column.name === 'user_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN user_id INTEGER')
database.exec(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_user_created
ON activity_logs(user_id, created_at DESC)
`)
}
const activityLogColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!activityLogColumns.some(column => column.name === 'backend_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT')
}
}
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
@@ -348,6 +373,9 @@ export function deleteSessionsForUser(userId: number): void {
export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
export const SETTINGS_HIDE_FOLDER_IN_URL = 'hide_folder_in_url'
export const SETTINGS_STORAGE_USE_DATE_PATH = 'storage_use_date_path'
export const SETTINGS_SITE_BASE_URL = 'site_base_url'
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
@@ -392,8 +420,8 @@ export function insertActivityLog(input: ActivityLogInput): void {
const createdAt = input.createdAt ?? new Date().toISOString()
getDb().prepare(`
INSERT INTO activity_logs
(action, key, original_name, size, content_type, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
(action, key, original_name, size, content_type, source, user_id, backend_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.action,
input.key,
@@ -401,10 +429,15 @@ export function insertActivityLog(input: ActivityLogInput): void {
input.size,
input.contentType,
input.source,
input.userId ?? null,
input.backendId ?? null,
createdAt
)
} catch (error) {
console.error('[activity-log] insert failed', error)
logException('activity-log insert failed', error, {
action: input.action,
key: input.key
})
}
}
@@ -414,6 +447,8 @@ export function listActivityLogs(options: {
action?: LogAction
source?: LogSource
folder?: string
userId?: number
search?: string
}): {
items: ActivityLogRow[]
total: number
@@ -423,26 +458,11 @@ export function listActivityLogs(options: {
} {
const database = getDb()
const pageSize = options.limit
const params: Array<string | number> = []
const where: string[] = []
const { whereSql, params } = buildActivityLogWhere(options)
if (options.action) {
where.push('action = ?')
params.push(options.action)
}
if (options.source) {
where.push('source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('key LIKE ?')
params.push(`${options.folder}/%`)
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
const countRow = database.prepare(`
SELECT COUNT(*) AS count
FROM activity_logs
FROM activity_logs al
${whereSql}
`).get(...params) as { count: number }
@@ -453,10 +473,25 @@ export function listActivityLogs(options: {
const offset = (safePage - 1) * pageSize
const rows = database.prepare(`
SELECT id, action, key, original_name, size, content_type, source, created_at
FROM activity_logs
SELECT
al.id,
al.action,
al.key,
al.original_name,
al.size,
al.content_type,
al.source,
al.user_id,
u.username,
al.backend_id,
sb.name AS backend_name,
sb.type AS backend_type,
al.created_at
FROM activity_logs al
LEFT JOIN users u ON u.id = al.user_id
LEFT JOIN storage_backends sb ON sb.id = al.backend_id
${whereSql}
ORDER BY id DESC
ORDER BY al.id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, offset) as unknown as ActivityLogRow[]
@@ -469,6 +504,74 @@ export function listActivityLogs(options: {
}
}
export function summarizeActivityLogs(options: {
source?: LogSource
folder?: string
userId?: number
search?: string
}): {
total: number
upload: number
delete: number
} {
const database = getDb()
const { whereSql, params } = buildActivityLogWhere(options, { includeAction: false })
const row = database.prepare(`
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN al.action = 'upload' THEN 1 ELSE 0 END), 0) AS upload_count,
COALESCE(SUM(CASE WHEN al.action = 'delete' THEN 1 ELSE 0 END), 0) AS delete_count
FROM activity_logs al
${whereSql}
`).get(...params) as { total: number, upload_count: number, delete_count: number } | undefined
return {
total: row?.total ?? 0,
upload: row?.upload_count ?? 0,
delete: row?.delete_count ?? 0
}
}
function buildActivityLogWhere(
options: {
action?: LogAction
source?: LogSource
folder?: string
userId?: number
search?: string
},
{ includeAction = true }: { includeAction?: boolean } = {}
): { whereSql: string, params: Array<string | number> } {
const params: Array<string | number> = []
const where: string[] = []
if (includeAction && options.action) {
where.push('al.action = ?')
params.push(options.action)
}
if (options.source) {
where.push('al.source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('al.key LIKE ?')
params.push(`${options.folder}/%`)
}
if (options.userId !== undefined) {
where.push('al.user_id = ?')
params.push(options.userId)
}
if (options.search) {
where.push('al.original_name LIKE ?')
params.push(`%${options.search}%`)
}
return {
whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '',
params
}
}
function countSince(action: LogAction, sinceIso: string): number {
const row = getDb().prepare(`
SELECT COUNT(*) AS total
+56 -2
View File
@@ -8,18 +8,23 @@ import {
getAutoDeleteDays,
getAutoDeleteDaysSource,
getRefererSource,
getSiteBaseUrl,
getSiteBaseUrlConfigured,
getSiteBaseUrlSource,
getWebpQuality,
getWebpQualitySource,
isApiUploadTokenEnvConfigured,
isAutoDeleteDaysEnvConfigured,
isRefererEnvConfigured,
isWebpQualityEnvConfigured
isWebpQualityEnvConfigured,
validateDomainSeparationPair
} from './env'
import {
getSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_API_UPLOAD_TOKEN,
SETTINGS_AUTO_DELETE_DAYS,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
generateApiUploadToken
} from './db'
@@ -34,9 +39,13 @@ vi.mock('./db', async (importOriginal) => {
})
vi.stubGlobal('useRuntimeConfig', () => ({
apiUploadToken: ''
apiUploadToken: '',
siteBaseUrl: '',
imageBaseUrl: ''
}))
vi.stubGlobal('getRequestURL', () => new URL('https://fallback.example.com'))
function mockEvent(): H3Event {
return {
node: { req: {}, res: {} },
@@ -201,3 +210,48 @@ describe('getAutoDeleteDays priority', () => {
expect(getAutoDeleteDaysSource()).toBe('default')
})
})
describe('getSiteBaseUrl priority', () => {
afterEach(() => {
vi.clearAllMocks()
delete process.env.SITE_BASE_URL
})
it('prefers database over environment variable', () => {
process.env.SITE_BASE_URL = 'https://env.example.com'
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_SITE_BASE_URL ? 'https://db.example.com' : null
)
const event = mockEvent()
expect(getSiteBaseUrlConfigured(event)).toBe('https://db.example.com')
expect(getSiteBaseUrlSource(event)).toBe('db')
expect(getSiteBaseUrl(event)).toBe('https://db.example.com')
})
it('falls back to request origin when unset', () => {
vi.mocked(getSetting).mockReturnValue(null)
const event = mockEvent()
expect(getSiteBaseUrl(event)).toBe('https://fallback.example.com')
expect(getSiteBaseUrlSource(event)).toBe('none')
})
})
describe('validateDomainSeparationPair', () => {
it('rejects missing values', () => {
expect(validateDomainSeparationPair('', 'https://pic.example.com')).toContain('同时填写')
})
it('rejects identical hostnames', () => {
expect(validateDomainSeparationPair(
'https://same.example.com',
'https://same.example.com'
)).toContain('相同主机名')
})
it('accepts different hostnames', () => {
expect(validateDomainSeparationPair(
'https://admin.example.com',
'https://pic.example.com'
)).toBeNull()
})
})
+185 -2
View File
@@ -6,7 +6,10 @@ import {
SETTINGS_API_UPLOAD_TOKEN,
SETTINGS_AUTO_DELETE_DAYS,
SETTINGS_AUTO_DELETE_ENABLED_AT,
SETTINGS_HIDE_FOLDER_IN_URL,
SETTINGS_STORAGE_USE_DATE_PATH,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_SITE_BASE_URL,
SETTINGS_WEBP_QUALITY,
isAllowRegistration
} from './db'
@@ -47,10 +50,27 @@ export function getAdminSecret(event: H3Event): string {
export type SettingSource = 'env' | 'db' | 'none'
export function normalizeImageBaseUrl(raw: string): string {
export function normalizeBaseUrl(raw: string): string {
return raw.trim().replace(/\/$/, '')
}
export function normalizeImageBaseUrl(raw: string): string {
return normalizeBaseUrl(raw)
}
export function normalizeSiteBaseUrl(raw: string): string {
return normalizeBaseUrl(raw)
}
export function hostnameFromBaseUrl(value: string): string | null {
if (!value) return null
try {
return new URL(value).hostname.toLowerCase()
} catch {
return null
}
}
export function getImageBaseUrlFromEnv(event: H3Event): string {
return normalizeImageBaseUrl(
getRuntimeEnv(event, 'IMAGE_BASE_URL', 'imageBaseUrl')
@@ -73,6 +93,45 @@ export function getImageBaseUrlConfigured(event: H3Event): string {
return getImageBaseUrlFromEnv(event)
}
export function getSiteBaseUrlFromEnv(event: H3Event): string {
return normalizeSiteBaseUrl(
getRuntimeEnv(event, 'SITE_BASE_URL', 'siteBaseUrl')
)
}
export function isSiteBaseUrlEnvConfigured(event: H3Event): boolean {
return getSiteBaseUrlFromEnv(event).length > 0
}
export function getSiteBaseUrlSource(event: H3Event): SettingSource {
if (getSetting(SETTINGS_SITE_BASE_URL) !== null) return 'db'
if (isSiteBaseUrlEnvConfigured(event)) return 'env'
return 'none'
}
export function getSiteBaseUrlConfigured(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_SITE_BASE_URL)
if (dbRaw !== null) return normalizeSiteBaseUrl(dbRaw)
return getSiteBaseUrlFromEnv(event)
}
export function getSiteBaseUrl(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_SITE_BASE_URL)
if (dbRaw !== null) {
const configured = normalizeSiteBaseUrl(dbRaw)
if (configured) return configured
} else {
const fromEnv = getSiteBaseUrlFromEnv(event)
if (fromEnv) return fromEnv
}
const requestUrl = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
return requestUrl.origin
}
export function getImageBaseUrl(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_IMAGE_BASE_URL)
if (dbRaw !== null) {
@@ -91,6 +150,79 @@ export function getImageBaseUrl(event: H3Event): string {
return requestUrl.origin
}
export function isHideFolderInUrlFromEnv(): boolean {
try {
return process.env.HIDE_FOLDER_IN_URL?.trim().toLowerCase() === 'true'
} catch {
return false
}
}
export function isHideFolderInUrlEnvConfigured(): boolean {
try {
const raw = process.env.HIDE_FOLDER_IN_URL?.trim().toLowerCase()
return raw === 'true' || raw === 'false'
} catch {
return false
}
}
export function getHideFolderInUrlSource(): SettingSource {
if (getSetting(SETTINGS_HIDE_FOLDER_IN_URL) !== null) return 'db'
if (isHideFolderInUrlEnvConfigured()) return 'env'
return 'none'
}
/** 优先级:SQLite settings → 环境变量 → 默认 false */
export function isHideFolderInUrl(_event?: H3Event): boolean {
const dbRaw = getSetting(SETTINGS_HIDE_FOLDER_IN_URL)
if (dbRaw !== null) return dbRaw === 'true'
return isHideFolderInUrlFromEnv()
}
export function isStorageUseDatePathFromEnv(): boolean {
try {
const layout = process.env.STORAGE_LAYOUT?.trim().toLowerCase()
if (layout === 'flat') return false
if (layout === 'date') return true
const raw = process.env.STORAGE_USE_DATE_PATH?.trim().toLowerCase()
if (raw === 'false') return false
if (raw === 'true') return true
return true
} catch {
return true
}
}
export function isStorageUseDatePathEnvConfigured(): boolean {
try {
const layout = process.env.STORAGE_LAYOUT?.trim().toLowerCase()
if (layout === 'flat' || layout === 'date') return true
const raw = process.env.STORAGE_USE_DATE_PATH?.trim().toLowerCase()
return raw === 'true' || raw === 'false'
} catch {
return false
}
}
export function getStorageUseDatePathSource(): SettingSource {
if (getSetting(SETTINGS_STORAGE_USE_DATE_PATH) !== null) return 'db'
if (isStorageUseDatePathEnvConfigured()) return 'env'
return 'none'
}
/** 优先级:SQLite settings → 环境变量 → 默认 true(按年/月分组) */
export function isStorageUseDatePath(_event?: H3Event): boolean {
const dbRaw = getSetting(SETTINGS_STORAGE_USE_DATE_PATH)
if (dbRaw !== null) return dbRaw !== 'false'
if (isStorageUseDatePathEnvConfigured()) return isStorageUseDatePathFromEnv()
return true
}
export function getStorageLayout(event?: H3Event): 'date' | 'flat' {
return isStorageUseDatePath(event) ? 'date' : 'flat'
}
export function getApiUploadTokenFromEnv(event: H3Event): string {
return (
getRuntimeEnv(event, 'API_UPLOAD_TOKEN', 'apiUploadToken')
@@ -294,7 +426,7 @@ export function isValidRefererHost(host: string): boolean {
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(host)
}
export function isValidImageBaseUrl(value: string): boolean {
export function isValidBaseUrl(value: string): boolean {
if (!value) return true
try {
const url = new URL(value)
@@ -304,13 +436,48 @@ export function isValidImageBaseUrl(value: string): boolean {
}
}
export function isValidImageBaseUrl(value: string): boolean {
return isValidBaseUrl(value)
}
export function isValidSiteBaseUrl(value: string): boolean {
return isValidBaseUrl(value)
}
export function validateDomainSeparationPair(
siteBaseUrl: string,
imageBaseUrl: string
): string | null {
if (!siteBaseUrl || !imageBaseUrl) {
return '启用域名分离时需同时填写网站域名与图片域名'
}
if (!isValidSiteBaseUrl(siteBaseUrl) || !isValidImageBaseUrl(imageBaseUrl)) {
return '域名需为 http(s) 地址,且末尾不加 /'
}
const siteHost = hostnameFromBaseUrl(siteBaseUrl)
const imageHost = hostnameFromBaseUrl(imageBaseUrl)
if (!siteHost || !imageHost) {
return '域名格式无效'
}
if (siteHost === imageHost) {
return '网站域名与图片域名不能使用相同主机名'
}
return null
}
export function getSettingsPayload(event: H3Event) {
const config = useRuntimeConfig(event)
const webpQuality = getWebpQuality(event)
const allowedRefererHosts = getAllowedRefererHostsRaw(event)
const siteBaseUrl = getSiteBaseUrl(event)
const imageBaseUrl = getImageBaseUrl(event)
const autoDeleteDays = getAutoDeleteDays(event)
const appVersion = config.appVersion as string
const domainSeparation = isDomainSeparationActive(event)
const hideFolderInUrl = isHideFolderInUrl(event)
const hideFolderInUrlSource = getHideFolderInUrlSource()
const storageUseDatePath = isStorageUseDatePath(event)
const storageUseDatePathSource = getStorageUseDatePathSource()
return {
apiUploadToken: getApiUploadToken(event),
@@ -321,9 +488,17 @@ export function getSettingsPayload(event: H3Event) {
allowedRefererHosts,
refererSource: getRefererSource(),
refererEnvFallback: getRefererHostsFromEnv(),
siteBaseUrl,
siteBaseUrlConfigured: getSiteBaseUrlConfigured(event),
siteBaseUrlSource: getSiteBaseUrlSource(event),
imageBaseUrl,
imageBaseUrlConfigured: getImageBaseUrlConfigured(event),
imageBaseUrlSource: getImageBaseUrlSource(event),
domainSeparation,
hideFolderInUrl,
hideFolderInUrlSource,
storageUseDatePath,
storageUseDatePathSource,
autoDeleteDays,
autoDeleteDaysSource: getAutoDeleteDaysSource(),
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
@@ -332,8 +507,16 @@ export function getSettingsPayload(event: H3Event) {
env: {
webpQuality,
refererConfigured: allowedRefererHosts.length > 0,
siteBaseUrl,
imageBaseUrl,
hideFolderInUrl,
appVersion
}
}
}
export function isDomainSeparationActive(event: H3Event): boolean {
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
return Boolean(siteHost && imageHost && siteHost !== imageHost)
}
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest'
import type { H3Event } from 'h3'
import {
shouldBlockImageHostRequest,
shouldBlockSiteHostImageRequest
} from './host-isolation'
function mockEvent(): H3Event {
return {
node: { req: {}, res: {} },
context: {}
} as H3Event
}
describe('shouldBlockImageHostRequest', () => {
const imageHost = 'image.example.com'
const siteHost = 'admin.example.com'
it('does not block when separation is inactive', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: false,
requestHost: imageHost,
method: 'GET',
pathname: '/settings'
})).toBe(false)
})
it('blocks non-image paths on image host', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
requestHost: imageHost,
method: 'GET',
pathname: '/settings'
})).toBe(true)
})
it('allows image paths on image host', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
requestHost: imageHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(false)
})
it('blocks POST on image host even for image paths', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
requestHost: imageHost,
method: 'POST',
pathname: '/images/2026/08/demo.webp'
})).toBe(true)
})
it('does not block site host admin paths', () => {
const event = mockEvent()
expect(shouldBlockImageHostRequest(event, {
separationActive: true,
imageHost,
requestHost: siteHost,
method: 'GET',
pathname: '/api/images'
})).toBe(false)
})
})
describe('shouldBlockSiteHostImageRequest', () => {
const imageHost = 'image.example.com'
const siteHost = 'admin.example.com'
it('does not block when separation is inactive', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: false,
requestHost: siteHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(false)
})
it('blocks image paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(true)
})
it('blocks legacy date short paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/2026/08/demo.webp',
hideFolder: true
})).toBe(true)
})
it('allows admin paths on site host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: siteHost,
method: 'GET',
pathname: '/'
})).toBe(false)
})
it('does not block image paths on image host', () => {
const event = mockEvent()
expect(shouldBlockSiteHostImageRequest(event, {
separationActive: true,
siteHost,
requestHost: imageHost,
method: 'GET',
pathname: '/images/2026/08/demo.webp'
})).toBe(false)
})
})
+91
View File
@@ -0,0 +1,91 @@
import type { H3Event } from 'h3'
import {
getImageBaseUrlConfigured,
getSiteBaseUrlConfigured,
hostnameFromBaseUrl,
isDomainSeparationActive,
isHideFolderInUrl
} from './env'
import { requestPathToImageKey } from './serve-image'
export function getConfiguredSiteHost(event: H3Event): string | null {
return hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
}
export function getConfiguredImageHost(event: H3Event): string | null {
return hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
}
export function shouldBlockImageHostRequest(
event: H3Event,
options?: {
method?: string
pathname?: string
requestHost?: string
separationActive?: boolean
imageHost?: string | null
siteHost?: string | null
hideFolder?: boolean
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
if (!separationActive) return false
const imageHost = options?.imageHost ?? getConfiguredImageHost(event)
if (!imageHost) return false
const requestUrl = options?.requestHost
? null
: getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
const requestHost = (options?.requestHost ?? requestUrl?.hostname ?? '').toLowerCase()
if (requestHost !== imageHost) return false
const method = (options?.method ?? getMethod(event)).toUpperCase()
if (method !== 'GET' && method !== 'HEAD') return true
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder
}) === null
}
/** 网站域禁止直链出图(仅图片域可 GET/HEAD 图片路径) */
export function shouldBlockSiteHostImageRequest(
event: H3Event,
options?: {
method?: string
pathname?: string
requestHost?: string
separationActive?: boolean
siteHost?: string | null
hideFolder?: boolean
}
): boolean {
const separationActive = options?.separationActive ?? isDomainSeparationActive(event)
if (!separationActive) return false
const siteHost = options?.siteHost ?? getConfiguredSiteHost(event)
if (!siteHost) return false
const requestUrl = options?.requestHost
? null
: getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
const requestHost = (options?.requestHost ?? requestUrl?.hostname ?? '').toLowerCase()
if (requestHost !== siteHost) return false
const method = (options?.method ?? getMethod(event)).toUpperCase()
if (method !== 'GET' && method !== 'HEAD') return false
const pathname = options?.pathname ?? requestUrl?.pathname ?? ''
const hideFolder = options?.hideFolder ?? isHideFolderInUrl(event)
return requestPathToImageKey(pathname, {
hideFolder
}) !== null
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { validateImageKey } from './image-key'
describe('image index key validation', () => {
it('accepts standard image keys used in index', () => {
expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(true)
expect(validateImageKey('images/abc.webp')).toBe(true)
})
it('rejects path traversal keys', () => {
expect(validateImageKey('../secret')).toBe(false)
expect(validateImageKey('images/../../etc/passwd')).toBe(false)
})
})
+482
View File
@@ -0,0 +1,482 @@
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { getDb } from './db'
import { getDataDir } from './data-dir'
import { DEFAULT_FOLDER, isValidFolderName, sortFolderNames, validateImageKey } from './image-key'
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
import {
ensureDefaultBackends,
ensureStorageSchema,
LOCAL_BACKEND_ID
} from './storage-backends'
import type { PaginatedResult, StoredImage, StoredImageMeta } from './storage/types'
const META_SUFFIX = '.meta.json'
export interface ImageIndexRow {
key: string
backend_id: string
user_id: number | null
folder: string
original_name: string
content_type: string
size: number
uploaded_at: string
}
function keyToFilePath(key: string): string {
return join(getDataDir(), ...key.split('/'))
}
async function readLocalMeta(key: string): Promise<Partial<StoredImageMeta>> {
try {
const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
return JSON.parse(raw) as Partial<StoredImageMeta>
} catch {
return {}
}
}
function rowToStoredImage(row: ImageIndexRow): StoredImage {
const uploadedMs = new Date(row.uploaded_at).getTime()
return {
key: row.key,
backendId: row.backend_id,
originalName: row.original_name,
uploadedAt: row.uploaded_at,
contentType: row.content_type,
size: row.size,
userId: row.user_id,
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
}
}
function buildUserFilterSql(
userFilter: number | 'admin' | undefined,
params: Array<string | number>
): string {
if (userFilter === undefined || userFilter === 'admin') return ''
params.push(userFilter)
return ' AND user_id = ?'
}
export function insertImageIndex(input: {
key: string
backendId: string
userId?: number | null
originalName: string
contentType: string
size: number
uploadedAt: string
}): void {
ensureStorageSchema()
const folder = input.key.split('/')[0] ?? DEFAULT_FOLDER
getDb().prepare(`
INSERT INTO images
(key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
backend_id = excluded.backend_id,
user_id = excluded.user_id,
folder = excluded.folder,
original_name = excluded.original_name,
content_type = excluded.content_type,
size = excluded.size,
uploaded_at = excluded.uploaded_at
`).run(
input.key,
input.backendId,
input.userId ?? null,
folder,
input.originalName,
input.contentType,
input.size,
input.uploadedAt
)
}
export function deleteImageIndex(key: string): void {
ensureStorageSchema()
getDb().prepare('DELETE FROM images WHERE key = ?').run(key)
}
export function getImageIndexRow(key: string): ImageIndexRow | null {
ensureStorageSchema()
const row = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
FROM images
WHERE key = ?
`).get(key) as ImageIndexRow | undefined
return row ?? null
}
export function getImageIndexAsStored(key: string): StoredImage | null {
const row = getImageIndexRow(key)
return row ? rowToStoredImage(row) : null
}
/** 隐藏目录 URL(YYYY/MM/file)反查完整 key;多目录同名时优先 images */
export function findImageKeyByDatePath(datePath: string): string | null {
if (!isHiddenImageDatePath(datePath)) return null
ensureStorageSchema()
const suffix = `/${datePath}`
const rows = getDb().prepare(`
SELECT key FROM images WHERE key LIKE ?
`).all(`%${suffix}`) as Array<{ key: string }>
if (rows.length === 0) {
const fallback = `${DEFAULT_FOLDER}/${datePath}`
return validateImageKey(fallback) ? fallback : null
}
if (rows.length === 1) return rows[0]!.key
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${datePath}`)
if (exactDefault) return exactDefault.key
const defaultFolder = rows.find(row => row.key.startsWith(`${DEFAULT_FOLDER}/`))
return defaultFolder?.key ?? rows[0]!.key
}
/** 隐藏目录 URL(纯文件名)反查完整 key;多目录同名时优先 images */
export function findImageKeyByFilename(filename: string): string | null {
if (!isBareImageFilename(filename)) return null
ensureStorageSchema()
const suffix = `/${filename}`
const rows = getDb().prepare(`
SELECT key FROM images WHERE key LIKE ?
`).all(`%${suffix}`) as Array<{ key: string }>
if (rows.length === 0) {
const flatFallback = `${DEFAULT_FOLDER}/${filename}`
return validateImageKey(flatFallback) ? flatFallback : null
}
if (rows.length === 1) return rows[0]!.key
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${filename}`)
if (exactDefault) return exactDefault.key
const defaultFolder = rows.find(row => row.key.startsWith(`${DEFAULT_FOLDER}/`))
return defaultFolder?.key ?? rows[0]!.key
}
async function readDirNames(path: string): Promise<string[]> {
try {
const entries = await fs.readdir(path, { withFileTypes: true })
return entries.filter(e => e.isDirectory()).map(e => e.name)
} catch {
return []
}
}
async function readFileNames(path: string): Promise<string[]> {
try {
const entries = await fs.readdir(path, { withFileTypes: true })
return entries.filter(e => e.isFile()).map(e => e.name)
} catch {
return []
}
}
async function listLocalImageKeys(): Promise<string[]> {
const keys: string[] = []
const dataDir = getDataDir()
const prefixes = await readDirNames(dataDir)
for (const prefix of prefixes) {
if (!isValidFolderName(prefix)) continue
const root = join(dataDir, prefix)
const rootFiles = await readFileNames(root)
for (const file of rootFiles) {
if (file.endsWith(META_SUFFIX)) continue
const key = `${prefix}/${file}`
if (validateImageKey(key)) keys.push(key)
}
const years = await readDirNames(root)
for (const year of years) {
if (!/^\d{4}$/.test(year)) continue
const months = await readDirNames(join(root, year))
for (const month of months) {
const files = await readFileNames(join(root, year, month))
for (const file of files) {
if (file.endsWith(META_SUFFIX)) continue
const key = `${prefix}/${year}/${month}/${file}`
if (validateImageKey(key)) keys.push(key)
}
}
}
}
return keys
}
export async function migrateLocalImagesToIndex(): Promise<number> {
ensureDefaultBackends()
const keys = await listLocalImageKeys()
let inserted = 0
for (const key of keys) {
const existing = getImageIndexRow(key)
if (existing) continue
let stat
try {
stat = await fs.stat(keyToFilePath(key))
} catch {
continue
}
const meta = await readLocalMeta(key)
const uploadedAt = meta.uploadedAt ?? stat.mtime.toISOString()
insertImageIndex({
key,
backendId: LOCAL_BACKEND_ID,
userId: meta.userId ?? null,
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
contentType: meta.contentType ?? 'application/octet-stream',
size: stat.size,
uploadedAt
})
inserted++
}
return inserted
}
export async function listFolders(): Promise<string[]> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT DISTINCT folder FROM images ORDER BY folder ASC
`).all() as Array<{ folder: string }>
const folders = rows.map(row => row.folder).filter(isValidFolderName)
return sortFolderNames(folders)
}
export async function listFoldersForUser(
userFilter?: number | 'admin'
): Promise<string[]> {
ensureStorageSchema()
const params: Array<string | number> = []
const userSql = buildUserFilterSql(userFilter, params)
const rows = getDb().prepare(`
SELECT DISTINCT folder FROM images WHERE 1=1${userSql} ORDER BY folder ASC
`).all(...params) as Array<{ folder: string }>
const folders = rows.map(row => row.folder).filter(isValidFolderName)
return sortFolderNames(folders)
}
function buildBackendFilterSql(
backendId: string | undefined,
params: Array<string | number>
): string {
if (!backendId) return ''
params.push(backendId)
return ' AND backend_id = ?'
}
export async function countImages(
folder?: string,
userFilter?: number | 'admin',
backendId?: string
): Promise<number> {
ensureStorageSchema()
const params: Array<string | number> = []
const clauses: string[] = ['1=1']
if (folder && isValidFolderName(folder)) {
clauses.push('folder = ?')
params.push(folder)
}
const userSql = buildUserFilterSql(userFilter, params)
const backendSql = buildBackendFilterSql(backendId, params)
const row = getDb().prepare(`
SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
`).get(...params) as { count: number }
return row.count
}
function paginateSlice<T>(
all: T[],
limit: number,
page?: number
): PaginatedResult<T> {
const total = all.length
const pageSize = limit
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
const requestedPage = Math.max(1, page ?? 1)
const safePage = Math.min(requestedPage, totalPages)
const start = (safePage - 1) * pageSize
const items = all.slice(start, start + pageSize)
return { items, total, page: safePage, pageSize, totalPages }
}
export async function listImages(options: {
limit: number
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const params: Array<string | number> = []
const clauses: string[] = ['1=1']
if (options.folder && isValidFolderName(options.folder)) {
clauses.push('folder = ?')
params.push(options.folder)
}
const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params)
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
ORDER BY uploaded_at DESC, key DESC
`).all(...params) as unknown as ImageIndexRow[]
const sliced = paginateSlice(rows, options.limit, options.page)
return {
...sliced,
items: sliced.items.map(rowToStoredImage)
}
}
export async function searchImages(options: {
query: string
limit: number
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const needle = options.query.trim().toLowerCase()
const params: Array<string | number> = []
const clauses: string[] = ['1=1']
if (options.folder && isValidFolderName(options.folder)) {
clauses.push('folder = ?')
params.push(options.folder)
}
const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, params)
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
FROM images
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
ORDER BY uploaded_at DESC, key DESC
`).all(...params) as unknown as ImageIndexRow[]
const matches = rows.filter((row) => {
if (!needle) return true
return row.key.toLowerCase().includes(needle)
|| row.original_name.toLowerCase().includes(needle)
})
const sliced = paginateSlice(matches, options.limit, options.page)
return {
...sliced,
items: sliced.items.map(rowToStoredImage)
}
}
export async function getFolderStorageStats(
userFilter?: number | 'admin'
): Promise<Array<{ folder: string, count: number, bytes: number }>> {
ensureStorageSchema()
const params: Array<string | number> = []
const userSql = buildUserFilterSql(userFilter, params)
const rows = getDb().prepare(`
SELECT folder, COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
FROM images
WHERE 1=1${userSql}
GROUP BY folder
ORDER BY bytes DESC, folder ASC
`).all(...params) as Array<{ folder: string, count: number, bytes: number }>
return rows
}
function startOfDayIsoInShanghai(now = new Date()): string {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
const d = shifted.getUTCDate()
return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
}
function startOfMonthIsoInShanghai(now = new Date()): string {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
}
export async function getUserScopedStorageStats(userId: number): Promise<{
storedCount: number
uploadBytesTotal: number
uploadToday: number
uploadMonth: number
byFolder: Array<{ folder: string, count: number, bytes: number }>
}> {
ensureStorageSchema()
const startOfDay = startOfDayIsoInShanghai()
const startOfMonth = startOfMonthIsoInShanghai()
const rows = getDb().prepare(`
SELECT folder, size, uploaded_at
FROM images
WHERE user_id = ?
`).all(userId) as Array<{ folder: string, size: number, uploaded_at: string }>
const map = new Map<string, { count: number, bytes: number }>()
let uploadBytesTotal = 0
let uploadToday = 0
let uploadMonth = 0
for (const row of rows) {
uploadBytesTotal += row.size
if (row.uploaded_at >= startOfDay) uploadToday += 1
if (row.uploaded_at >= startOfMonth) uploadMonth += 1
const current = map.get(row.folder) ?? { count: 0, bytes: 0 }
current.count += 1
current.bytes += row.size
map.set(row.folder, current)
}
const byFolder = [...map.entries()]
.map(([folder, stat]) => ({ folder, ...stat }))
.sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
return {
storedCount: rows.length,
uploadBytesTotal,
uploadToday,
uploadMonth,
byFolder
}
}
export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
FROM images
ORDER BY uploaded_at ASC
`).all() as unknown as ImageIndexRow[]
return rows.map(rowToStoredImage)
}
+29 -2
View File
@@ -1,12 +1,22 @@
import { describe, expect, it } from 'vitest'
import { validateImageKey } from './image-key'
import { generateImageKey, sortFolderNames, validateImageKey } from './image-key'
describe('validateImageKey', () => {
it('accepts valid keys', () => {
it('accepts valid date-layout keys', () => {
expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/2025/01/photo.jpg')).toBe(true)
})
it('accepts valid flat-layout keys', () => {
expect(validateImageKey('images/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/photo.jpg')).toBe(true)
})
it('sorts images first then alphabetically', () => {
expect(sortFolderNames(['blog', 'images', 'twikoo'])).toEqual(['images', 'blog', 'twikoo'])
expect(sortFolderNames(['blog'])).toEqual(['images', 'blog'])
})
it('rejects path traversal and malformed keys', () => {
expect(validateImageKey('')).toBe(false)
expect(validateImageKey('/images/2026/08/a.webp')).toBe(false)
@@ -15,5 +25,22 @@ describe('validateImageKey', () => {
expect(validateImageKey('api/2026/08/a.webp')).toBe(false)
expect(validateImageKey('images/26/8/a.webp')).toBe(false)
expect(validateImageKey('images/2026/08/a.txt')).toBe(false)
expect(validateImageKey('images/2026/a.webp')).toBe(false)
})
})
describe('generateImageKey', () => {
const date = new Date('2026-08-15T12:00:00Z')
it('generates date-layout keys by default', () => {
const key = generateImageKey('image/webp', date, 'images')
expect(key).toMatch(/^images\/2026\/08\/[A-Za-z0-9]+\.webp$/)
expect(validateImageKey(key)).toBe(true)
})
it('generates flat-layout keys when requested', () => {
const key = generateImageKey('image/webp', date, 'images', 'flat')
expect(key).toMatch(/^images\/[A-Za-z0-9]+\.webp$/)
expect(validateImageKey(key)).toBe(true)
})
})
+49 -8
View File
@@ -11,6 +11,8 @@ const EXT_MAP: Record<AllowedMimeType, string> = {
export const DEFAULT_FOLDER = 'images'
export type StorageLayout = 'date' | 'flat'
/** 避免与站点路由 / 静态资源冲突 */
const RESERVED_FOLDERS = new Set([
'api',
@@ -25,6 +27,8 @@ const RESERVED_FOLDERS = new Set([
'sw'
])
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
function generateRandomId(length = 12): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const bytes = new Uint8Array(length)
@@ -49,15 +53,37 @@ export function normalizeFolderName(raw: string | undefined | null): string {
return trimmed
}
/** 默认目录 images 始终排在首位,其余按字母序 */
export function sortFolderNames(folders: string[]): string[] {
const seen = new Set<string>()
const valid: string[] = []
for (const folder of folders) {
if (!isValidFolderName(folder) || seen.has(folder)) continue
seen.add(folder)
valid.push(folder)
}
if (!seen.has(DEFAULT_FOLDER)) {
valid.push(DEFAULT_FOLDER)
}
const rest = valid
.filter(folder => folder !== DEFAULT_FOLDER)
.sort((a, b) => a.localeCompare(b))
return [DEFAULT_FOLDER, ...rest]
}
export function generateImageKey(
contentType: AllowedMimeType,
date = new Date(),
prefix = DEFAULT_FOLDER
prefix = DEFAULT_FOLDER,
layout: StorageLayout = 'date'
): string {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const id = generateRandomId(12)
const ext = mimeToExtension(contentType)
if (layout === 'flat') {
return `${prefix}/${id}.${ext}`
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${prefix}/${year}/${month}/${id}.${ext}`
}
@@ -67,9 +93,24 @@ export function validateImageKey(key: string): boolean {
if (key.includes('..')) return false
if (key.includes('//')) return false
const match = key.match(
/^([A-Za-z0-9][A-Za-z0-9_-]*)\/\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
)
if (!match) return false
return isValidFolderName(match[1]!)
const parts = key.split('/')
if (parts.length < 2 || parts.some(part => !part)) return false
const folder = parts[0]!
if (!isValidFolderName(folder)) return false
const filename = parts[parts.length - 1]!
if (!IMAGE_EXT_PATTERN.test(filename)) return false
if (parts.length === 2) {
return true
}
if (parts.length === 4) {
const year = parts[1]!
const month = parts[2]!
return /^\d{4}$/.test(year) && /^\d{2}$/.test(month)
}
return false
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import {
isBareImageFilename,
isHiddenImageDatePath,
toPublicImagePath
} from './image-public-path'
describe('toPublicImagePath', () => {
it('returns key unchanged when hideFolder is false', () => {
expect(toPublicImagePath('images/2026/08/a.webp', false)).toBe('images/2026/08/a.webp')
expect(toPublicImagePath('images/a.webp', false)).toBe('images/a.webp')
})
it('returns basename when hideFolder is true', () => {
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('a.webp')
expect(toPublicImagePath('images/a.webp', true)).toBe('a.webp')
})
})
describe('isHiddenImageDatePath', () => {
it('accepts YYYY/MM/file paths', () => {
expect(isHiddenImageDatePath('2026/08/demo.webp')).toBe(true)
})
it('rejects folder-prefixed paths', () => {
expect(isHiddenImageDatePath('images/2026/08/demo.webp')).toBe(false)
})
})
describe('isBareImageFilename', () => {
it('accepts bare filenames', () => {
expect(isBareImageFilename('demo.webp')).toBe(true)
})
it('rejects paths with directories', () => {
expect(isBareImageFilename('2026/08/demo.webp')).toBe(false)
expect(isBareImageFilename('images/demo.webp')).toBe(false)
})
})
+21
View File
@@ -0,0 +1,21 @@
import { basename } from 'node:path'
/** 对外 URL 路径:可选隐藏目录前缀(输出纯文件名) */
export function toPublicImagePath(key: string, hideFolder: boolean): string {
if (!hideFolder) return key
return basename(key)
}
const HIDDEN_DATE_PATH_PATTERN
= /^\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
const BARE_FILENAME_PATTERN
= /^[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
export function isHiddenImageDatePath(path: string): boolean {
return HIDDEN_DATE_PATH_PATTERN.test(path)
}
export function isBareImageFilename(path: string): boolean {
return BARE_FILENAME_PATTERN.test(path)
}
+9
View File
@@ -0,0 +1,9 @@
import { getStorageBackendRow } from './storage-backends'
export function readBackendIdQuery(
query: Record<string, unknown>
): string | undefined | null {
const raw = typeof query.backendId === 'string' ? query.backendId.trim() : ''
if (!raw || raw === 'all') return undefined
return getStorageBackendRow(raw)?.id ?? null
}
+97 -9
View File
@@ -2,7 +2,12 @@ import type { H3Event } from 'h3'
import type { ImageItem } from '~/types/image'
import type { AllowedMimeType } from './constants'
import type { StoredImage } from './storage'
import { getImageBaseUrl } from './env'
import { getImageBaseUrl, isHideFolderInUrl } from './env'
import { toPublicImagePath } from './image-public-path'
import { getActiveBackendRow } from './storage/resolver'
import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends'
import type { StorageBackendType, StorageBackendRow } from './storage/types'
import { buildObjectKey, parseS3Config } from './storage/s3'
export function resolveImageOwner(
userId: number | null | undefined,
@@ -20,14 +25,37 @@ export function resolveImageOwner(
}
}
export function resolveImageStorage(
backendId: string | undefined,
backendMap?: Map<string, { name: string, type: StorageBackendType }>
): ImageItem['storage'] | undefined {
if (!backendId) return undefined
const fromMap = backendMap?.get(backendId)
if (fromMap) {
return { id: backendId, name: fromMap.name, type: fromMap.type }
}
const row = getStorageBackendRow(backendId)
if (row) {
return { id: backendId, name: row.name, type: row.type }
}
return { id: backendId, name: backendId, type: 's3' }
}
export function mapStoredImageToItem(
event: H3Event,
stored: StoredImage,
options?: { ownerMap?: Map<number, string> }
options?: {
ownerMap?: Map<number, string>
backendMap?: Map<string, { name: string, type: StorageBackendType }>
}
): ImageItem {
const item = buildImageItem({
key: stored.key,
baseUrl: getImageBaseUrl(event),
event,
backendId: stored.backendId,
originalName: stored.originalName,
contentType: stored.contentType,
size: stored.size,
@@ -35,16 +63,72 @@ export function mapStoredImageToItem(
})
const owner = resolveImageOwner(stored.userId, options?.ownerMap)
if (owner) {
return { ...item, owner }
const storage = resolveImageStorage(stored.backendId, options?.backendMap)
if (owner || storage) {
return {
...item,
...(owner ? { owner } : {}),
...(storage ? { storage } : {})
}
}
return item
}
export function buildImageUrl(baseUrl: string, key: string): string {
export function buildImageUrl(
baseUrl: string,
key: string,
hideFolder = false
): string {
const normalizedBase = baseUrl.replace(/\/$/, '')
return `${normalizedBase}/${key}`
const path = toPublicImagePath(key, hideFolder)
return `${normalizedBase}/${path}`
}
export function buildPublicImageUrl(
backendRow: StorageBackendRow,
key: string,
hideFolder = false
): string {
const base = backendRow.public_url.replace(/\/$/, '')
const path = toPublicImagePath(key, hideFolder)
if (backendRow.type === 's3') {
const config = parseS3Config(backendRow.config_json)
const objectKey = buildObjectKey(config?.prefix, key)
const publicPath = hideFolder ? toPublicImagePath(key, true) : objectKey
return `${base}/${publicPath}`
}
return `${base}/${path}`
}
export function resolveImageUrl(
event: H3Event,
key: string,
backendId?: string
): string {
const hideFolder = isHideFolderInUrl(event)
const envConfig = getStorageBackendFromEnv()
if (envConfig && (!backendId || backendId === 's3-primary')) {
if (envConfig.servingMode === 'public' && envConfig.publicUrl) {
const prefix = envConfig.config.prefix as string | undefined
const objectKey = buildObjectKey(prefix, key)
const base = envConfig.publicUrl.replace(/\/$/, '')
const publicPath = hideFolder ? toPublicImagePath(key, true) : objectKey
return `${base}/${publicPath}`
}
return buildImageUrl(getImageBaseUrl(event), key, hideFolder)
}
const backendRow = backendId
? getStorageBackendRow(backendId)
: getActiveBackendRow()
if (backendRow?.serving_mode === 'public' && backendRow.public_url) {
return buildPublicImageUrl(backendRow, key, hideFolder)
}
return buildImageUrl(getImageBaseUrl(event), key, hideFolder)
}
export function buildMarkdown(url: string, alt: string): string {
@@ -68,13 +152,17 @@ function escapeHtml(value: string): string {
export function buildImageItem(input: {
key: string
baseUrl: string
baseUrl?: string
event?: H3Event
backendId?: string
originalName: string
contentType: AllowedMimeType | string
size: number
uploadedAt: string
}) {
const url = buildImageUrl(input.baseUrl, input.key)
const url = input.event
? resolveImageUrl(input.event, input.key, input.backendId)
: buildImageUrl(input.baseUrl ?? '', input.key)
const alt = sanitizeOriginalName(input.originalName)
return {
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { serializeError } from './logger'
describe('serializeError', () => {
it('extracts message and status from H3-like errors', () => {
const error = new Error('bucket not found') as Error & {
statusCode: number
statusMessage: string
data: { error: { code: string } }
}
error.statusCode = 404
error.statusMessage = '存储桶不存在'
error.data = { error: { code: 'NOT_FOUND' } }
expect(serializeError(error)).toMatchObject({
name: 'Error',
message: 'bucket not found',
statusCode: 404,
statusMessage: '存储桶不存在',
data: { error: { code: 'NOT_FOUND' } }
})
})
it('includes stack for 5xx errors at info log level', () => {
const error = new Error('db locked') as Error & { statusCode: number }
error.statusCode = 500
error.stack = 'Error: db locked\n at test'
const serialized = serializeError(error)
expect(serialized.stack).toBe(error.stack)
})
})
+40
View File
@@ -7,6 +7,12 @@ const LEVEL_ORDER: Record<LogLevel, number> = {
error: 40
}
type H3LikeError = Error & {
statusCode?: number
statusMessage?: string
data?: unknown
}
function currentLevel(): LogLevel {
const raw = (process.env.LOG_LEVEL || 'info').toLowerCase()
if (raw === 'debug' || raw === 'info' || raw === 'warn' || raw === 'error') {
@@ -27,6 +33,35 @@ function formatLine(level: LogLevel, message: string, extra?: Record<string, unk
return `[${time}] [${level.toUpperCase()}] ${message}${payload}`
}
/** 将 unknown / Error / H3Error 转为可 JSON 输出的字段,便于 Docker 日志排查 */
export function serializeError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
const h3 = error as H3LikeError
const out: Record<string, unknown> = {
name: error.name,
message: error.message
}
if (h3.statusCode !== undefined) out.statusCode = h3.statusCode
if (h3.statusMessage) out.statusMessage = h3.statusMessage
if (h3.data !== undefined) out.data = h3.data
if (error.stack && shouldLog('debug')) out.stack = error.stack
else if (error.stack && (h3.statusCode ?? 500) >= 500) out.stack = error.stack
if ('cause' in error && error.cause !== undefined) {
out.cause = serializeError(error.cause)
}
return out
}
if (typeof error === 'string') return { message: error }
if (error && typeof error === 'object') {
try {
return { detail: JSON.parse(JSON.stringify(error)) }
} catch {
return { message: String(error) }
}
}
return { message: String(error) }
}
export function logDebug(message: string, extra?: Record<string, unknown>) {
if (!shouldLog('debug')) return
console.debug(formatLine('debug', message, extra))
@@ -47,6 +82,11 @@ export function logError(message: string, extra?: Record<string, unknown>) {
console.error(formatLine('error', message, extra))
}
/** message + 原始 error 对象(自动 serialize) */
export function logException(message: string, error: unknown, extra?: Record<string, unknown>) {
logError(message, { ...extra, error: serializeError(error) })
}
export function clientIp(event: Parameters<typeof getRequestIP>[0]): string {
return getRequestIP(event, { xForwardedFor: true }) || '-'
}
+12 -8
View File
@@ -14,10 +14,10 @@ import {
import type { LogSource } from './db'
import { generateImageKey } from './image-key'
import { buildImageItem, sanitizeOriginalName } from './image-response'
import { getImageBaseUrl, getWebpQuality } from './env'
import { getStorageLayout, getWebpQuality } from './env'
import { insertActivityLog } from './db'
import { putImage } from './storage'
import { logInfo, logWarn } from './logger'
import { logInfo, logException } from './logger'
/** 可转 WebP 的位图格式;SVG(矢量)、ICO、已是 WebP 的原样存储 */
const WEBP_CONVERTIBLE: readonly AllowedMimeType[] = [
@@ -121,24 +121,26 @@ export async function processSingleImageUpload(
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event))
const imageBaseUrl = getImageBaseUrl(event)
const uploadedAt = new Date().toISOString()
const layout = getStorageLayout(event)
const key = generateImageKey(
compressed.mime,
new Date(uploadedAt),
input.prefix ?? 'images'
input.prefix ?? 'images',
layout
)
let backendId: string
try {
await putImage(key, compressed.bytes, {
backendId = await putImage(key, compressed.bytes, {
originalName,
uploadedAt,
contentType: compressed.mime,
size: compressed.bytes.byteLength,
userId: input.userId ?? null
})
} catch {
logWarn('upload failed: storage error', {
} catch (error) {
logException('upload failed: storage error', error, {
name: originalName,
source: input.source ?? 'web'
})
@@ -160,6 +162,8 @@ export async function processSingleImageUpload(
size: compressed.bytes.byteLength,
contentType: compressed.mime,
source: input.source ?? 'web',
userId: input.userId ?? null,
backendId,
createdAt: uploadedAt
})
@@ -173,7 +177,7 @@ export async function processSingleImageUpload(
return {
item: buildImageItem({
key,
baseUrl: imageBaseUrl,
event,
originalName,
contentType: compressed.mime,
size: compressed.bytes.byteLength,
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
import { checkUploadRateLimit } from './rate-limit'
vi.mock('./logger', () => ({
clientIp: (event: { __ip?: string }) => event.__ip ?? '127.0.0.1'
}))
function mockEvent(ip = '127.0.0.1', authToken = '') {
return {
__ip: ip,
node: {
req: {
headers: {
...(authToken ? { 'auth-token': authToken } : {})
}
}
}
} as unknown as Parameters<typeof checkUploadRateLimit>[0]
}
describe('checkUploadRateLimit', () => {
it('allows requests under the IP limit', () => {
const event = mockEvent('10.0.0.1')
expect(() => checkUploadRateLimit(event)).not.toThrow()
})
it('limits repeated uploads by IP', () => {
const event = mockEvent('10.0.0.3')
for (let i = 0; i < 60; i++) {
checkUploadRateLimit(event)
}
expect(() => checkUploadRateLimit(event)).toThrow()
})
it('limits repeated API token uploads', () => {
const token = 'token-a'
for (let i = 0; i < 120; i++) {
checkUploadRateLimit(mockEvent(`10.0.${i % 200}`), token)
}
expect(() => checkUploadRateLimit(mockEvent('10.0.250'), token)).toThrow()
})
})
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto'
import { createError, getHeader, type H3Event } from 'h3'
import { clientIp } from './logger'
const UPLOAD_IP_MAX = 60
const UPLOAD_TOKEN_MAX = 120
const UPLOAD_WINDOW_MS = 15 * 60 * 1000
const stores = new Map<string, Map<string, { count: number, resetAt: number }>>()
function getStore(name: string): Map<string, { count: number, resetAt: number }> {
let store = stores.get(name)
if (!store) {
store = new Map()
stores.set(name, store)
}
return store
}
function assertRateLimit(
storeName: string,
key: string,
max: number,
windowMs: number,
message: string
): void {
const store = getStore(storeName)
const now = Date.now()
const entry = store.get(key)
if (!entry || now > entry.resetAt) {
store.set(key, { count: 1, resetAt: now + windowMs })
return
}
entry.count += 1
if (entry.count > max) {
throw createError({
statusCode: 429,
statusMessage: message
})
}
}
export function checkUploadRateLimit(event: H3Event, apiToken?: string): void {
const ip = clientIp(event)
assertRateLimit(
'upload-ip',
ip,
UPLOAD_IP_MAX,
UPLOAD_WINDOW_MS,
'上传过于频繁,请稍后再试'
)
const headerToken = getHeader(event, 'auth-token')?.trim() ?? ''
const token = apiToken?.trim() || headerToken
if (!token) return
const hash = createHash('sha256').update(token).digest('hex').slice(0, 32)
assertRateLimit(
'upload-token',
hash,
UPLOAD_TOKEN_MAX,
UPLOAD_WINDOW_MS,
'上传过于频繁,请稍后再试'
)
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { requestPathToImageKey } from './serve-image'
describe('requestPathToImageKey hide folder', () => {
it('resolves bare filename to default folder flat key', () => {
expect(requestPathToImageKey('/demo.webp', { hideFolder: true }))
.toBe('images/demo.webp')
})
it('resolves hidden date path to default folder key', () => {
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: true }))
.toBe('images/2026/08/demo.webp')
})
it('still accepts full key when hide folder is enabled', () => {
expect(requestPathToImageKey('/images/2026/08/demo.webp', { hideFolder: true }))
.toBe('images/2026/08/demo.webp')
expect(requestPathToImageKey('/images/demo.webp', { hideFolder: true }))
.toBe('images/demo.webp')
})
it('does not resolve short paths when hide folder is disabled', () => {
expect(requestPathToImageKey('/2026/08/demo.webp', { hideFolder: false }))
.toBeNull()
expect(requestPathToImageKey('/demo.webp', { hideFolder: false }))
.toBeNull()
})
it('accepts flat full key when hide folder is disabled', () => {
expect(requestPathToImageKey('/images/demo.webp', { hideFolder: false }))
.toBe('images/demo.webp')
})
})
+40 -7
View File
@@ -1,8 +1,16 @@
import type { H3Event } from 'h3'
import { IMAGE_CACHE_CONTROL } from './constants'
import { getAllowedRefererHostsRaw } from './env'
import {
getAllowedRefererHostsRaw,
getImageBaseUrlConfigured,
getSiteBaseUrlConfigured,
hostnameFromBaseUrl
} from './env'
import { findImageKeyByDatePath, findImageKeyByFilename } from './image-index'
import { validateImageKey } from './image-key'
import { buildPublicImageUrl } from './image-response'
import { createImageStream, headImage } from './storage'
import { getBackendRowForKey } from './storage/resolver'
/**
* 图片直链处理(/{type}/YYYY/MM/file)
@@ -12,7 +20,7 @@ import { createImageStream, headImage } from './storage'
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
*/
const ERROR_CACHE_CONTROL = 'public, max-age=60'
const ERROR_CACHE_CONTROL = 'private, no-cache, max-age=0'
function getAllowedHosts(event: H3Event): Set<string> {
const hosts = new Set<string>()
@@ -29,6 +37,12 @@ function getAllowedHosts(event: H3Event): Set<string> {
})
hosts.add(requestUrl.hostname.toLowerCase())
// 双域名分离时,管理域页面会引用图片域直链,需互相放行 Referer
const siteHost = hostnameFromBaseUrl(getSiteBaseUrlConfigured(event))
const imageHost = hostnameFromBaseUrl(getImageBaseUrlConfigured(event))
if (siteHost) hosts.add(siteHost)
if (imageHost) hosts.add(imageHost)
return hosts
}
@@ -61,9 +75,22 @@ function parseRangeHeader(
return { start, end: safeEnd }
}
export function requestPathToImageKey(pathname: string): string | null {
const key = decodeURIComponent(pathname).replace(/^\/+/, '')
return validateImageKey(key) ? key : null
export function requestPathToImageKey(
pathname: string,
options?: { hideFolder?: boolean }
): string | null {
const raw = decodeURIComponent(pathname).replace(/^\/+/, '')
if (validateImageKey(raw)) return raw
if (options?.hideFolder) {
const byFilename = findImageKeyByFilename(raw)
if (byFilename && validateImageKey(byFilename)) return byFilename
const byDatePath = findImageKeyByDatePath(raw)
if (byDatePath && validateImageKey(byDatePath)) return byDatePath
}
return null
}
export async function serveImageByKey(event: H3Event, key: string) {
@@ -82,6 +109,12 @@ export async function serveImageByKey(event: H3Event, key: string) {
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
const backendRow = await getBackendRowForKey(key)
if (backendRow.serving_mode === 'public' && backendRow.public_url) {
const publicUrl = buildPublicImageUrl(backendRow, key)
return sendRedirect(event, publicUrl, 302)
}
const etag = `"${stored.size}-${Math.round(stored.mtimeMs)}"`
setHeader(event, 'ETag', etag)
@@ -116,9 +149,9 @@ export async function serveImageByKey(event: H3Event, key: string) {
setResponseStatus(event, 206)
setHeader(event, 'Content-Range', `bytes ${range.start}-${range.end}/${stored.size}`)
setHeader(event, 'Content-Length', range.end - range.start + 1)
return sendStream(event, createImageStream(key, range))
return sendStream(event, await createImageStream(key, range))
}
setHeader(event, 'Content-Length', stored.size)
return sendStream(event, createImageStream(key))
return sendStream(event, await createImageStream(key))
}
+437
View File
@@ -0,0 +1,437 @@
import type { DatabaseSync } from 'node:sqlite'
import { getDb } from './db'
import { LocalStorageBackend } from './storage/local'
import {
parseS3Config,
parseS3Secrets,
S3StorageBackend
} from './storage/s3'
import type {
S3BackendConfig,
ServingMode,
StorageBackendInfo,
StorageBackendRow,
StorageBackendType
} from './storage/types'
export const LOCAL_BACKEND_ID = 'local'
export const S3_BACKEND_ID = 's3-primary'
function migrateStorageTables(database: DatabaseSync): void {
database.exec(`
CREATE TABLE IF NOT EXISTS storage_backends (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('local', 's3')),
config_json TEXT NOT NULL DEFAULT '{}',
secret_json TEXT NOT NULL DEFAULT '{}',
serving_mode TEXT NOT NULL DEFAULT 'proxy' CHECK(serving_mode IN ('proxy', 'public')),
public_url TEXT NOT NULL DEFAULT '',
quota_bytes INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS images (
key TEXT PRIMARY KEY,
backend_id TEXT NOT NULL,
user_id INTEGER,
folder TEXT NOT NULL,
original_name TEXT NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
uploaded_at TEXT NOT NULL,
FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
);
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
CREATE INDEX IF NOT EXISTS idx_images_backend_id ON images(backend_id);
CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);
CREATE INDEX IF NOT EXISTS idx_images_folder ON images(folder);
`)
}
export function ensureStorageSchema(): void {
migrateStorageTables(getDb())
}
export function ensureDefaultBackends(): void {
ensureStorageSchema()
const db = getDb()
const local = db.prepare(`
SELECT id FROM storage_backends WHERE id = ?
`).get(LOCAL_BACKEND_ID)
if (!local) {
db.prepare(`
INSERT INTO storage_backends
(id, name, type, config_json, secret_json, serving_mode, public_url,
quota_bytes, enabled, is_default, sort_order)
VALUES (?, ?, 'local', '{}', '{}', 'proxy', '', NULL, 1, 1, 0)
`).run(LOCAL_BACKEND_ID, '本地磁盘')
}
cleanupPlaceholderS3Backend(db)
}
/** 移除早期版本自动种下的空 S3 占位(未配置、未启用、非默认、无图片) */
function cleanupPlaceholderS3Backend(db: DatabaseSync): void {
const row = db.prepare(`
SELECT * FROM storage_backends WHERE id = ?
`).get(S3_BACKEND_ID) as StorageBackendRow | undefined
if (!row || row.type !== 's3' || row.is_default === 1) {
return
}
const config = parseS3Config(row.config_json)
const secrets = parseS3Secrets(row.secret_json)
const configured = Boolean(
config?.bucket?.trim()
|| config?.endpoint?.trim()
|| secrets?.accessKeyId?.trim()
|| secrets?.secretAccessKey?.trim()
)
if (configured) {
return
}
const usage = getBackendUsageStats(S3_BACKEND_ID)
if (usage.count > 0) {
return
}
db.prepare('DELETE FROM storage_backends WHERE id = ?').run(S3_BACKEND_ID)
}
function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendInfo & {
secretsMasked: Record<string, string>
} {
const config = row.type === 's3'
? (parseS3Config(row.config_json) ?? {})
: {}
let secretsMasked: Record<string, string> = {}
if (row.type === 's3') {
const secrets = parseS3Secrets(row.secret_json)
secretsMasked = {
accessKeyId: secrets?.accessKeyId
? maskSecret(secrets.accessKeyId)
: '',
secretAccessKey: secrets?.secretAccessKey
? maskSecret(secrets.secretAccessKey)
: ''
}
}
return {
id: row.id,
name: row.name,
type: row.type,
config,
servingMode: row.serving_mode,
publicUrl: row.public_url,
quotaBytes: row.quota_bytes,
enabled: row.enabled === 1,
isDefault: row.is_default === 1,
sortOrder: row.sort_order,
secretsMasked
}
}
function maskSecret(value: string): string {
if (value.length <= 4) return '****'
return `${value.slice(0, 4)}${'*'.repeat(Math.min(8, value.length - 4))}`
}
export function listStorageBackendRows(): StorageBackendRow[] {
ensureDefaultBackends()
return getDb().prepare(`
SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
quota_bytes, enabled, is_default, sort_order
FROM storage_backends
ORDER BY sort_order ASC, id ASC
`).all() as unknown as StorageBackendRow[]
}
export function listStorageBackends(maskSecrets = true): Array<StorageBackendInfo & {
secretsMasked: Record<string, string>
}> {
return listStorageBackendRows().map(row => rowToInfo(row, maskSecrets))
}
export function listStorageBackendNameMap(): Map<string, {
name: string
type: StorageBackendType
}> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT id, name, type FROM storage_backends
`).all() as Array<{ id: string, name: string, type: StorageBackendType }>
return new Map(rows.map(row => [row.id, { name: row.name, type: row.type }]))
}
export function getStorageBackendRow(id: string): StorageBackendRow | null {
ensureDefaultBackends()
const row = getDb().prepare(`
SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
quota_bytes, enabled, is_default, sort_order
FROM storage_backends
WHERE id = ?
`).get(id) as StorageBackendRow | undefined
return row ?? null
}
export function getDefaultBackendRow(): StorageBackendRow | null {
ensureDefaultBackends()
const row = getDb().prepare(`
SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
quota_bytes, enabled, is_default, sort_order
FROM storage_backends
WHERE is_default = 1 AND enabled = 1
LIMIT 1
`).get() as StorageBackendRow | undefined
return row ?? null
}
export function getBackendIdForKey(key: string): string | null {
ensureStorageSchema()
const row = getDb().prepare(`
SELECT backend_id FROM images WHERE key = ?
`).get(key) as { backend_id: string } | undefined
return row?.backend_id ?? null
}
export function setDefaultBackend(id: string): void {
ensureDefaultBackends()
const db = getDb()
db.exec('UPDATE storage_backends SET is_default = 0')
db.prepare(`
UPDATE storage_backends SET is_default = 1, enabled = 1 WHERE id = ?
`).run(id)
}
export interface StorageBackendPatch {
name?: string
config?: Record<string, unknown>
secrets?: { accessKeyId?: string, secretAccessKey?: string }
servingMode?: ServingMode
publicUrl?: string
quotaBytes?: number | null
enabled?: boolean
isDefault?: boolean
}
export function updateStorageBackend(id: string, patch: StorageBackendPatch): void {
const row = getStorageBackendRow(id)
if (!row) {
throw new Error(`Storage backend not found: ${id}`)
}
const name = patch.name ?? row.name
let configJson = row.config_json
let secretJson = row.secret_json
if (patch.config !== undefined) {
const current = row.type === 's3'
? (parseS3Config(row.config_json) ?? {})
: {}
configJson = JSON.stringify({ ...current, ...patch.config })
}
if (patch.secrets !== undefined && row.type === 's3') {
const current = parseS3Secrets(row.secret_json) ?? {
accessKeyId: '',
secretAccessKey: ''
}
const next = {
accessKeyId: patch.secrets.accessKeyId?.trim()
? patch.secrets.accessKeyId.trim()
: current.accessKeyId,
secretAccessKey: patch.secrets.secretAccessKey?.trim()
? patch.secrets.secretAccessKey.trim()
: current.secretAccessKey
}
secretJson = JSON.stringify(next)
}
const servingMode = patch.servingMode ?? row.serving_mode
const publicUrl = patch.publicUrl ?? row.public_url
const quotaBytes = patch.quotaBytes !== undefined
? patch.quotaBytes
: row.quota_bytes
const enabled = patch.enabled !== undefined
? (patch.enabled ? 1 : 0)
: row.enabled
getDb().prepare(`
UPDATE storage_backends
SET name = ?, config_json = ?, secret_json = ?, serving_mode = ?,
public_url = ?, quota_bytes = ?, enabled = ?
WHERE id = ?
`).run(name, configJson, secretJson, servingMode, publicUrl, quotaBytes, enabled, id)
if (patch.isDefault) {
setDefaultBackend(id)
}
}
export function isStorageEnvConfigured(): boolean {
return getStorageBackendFromEnv() !== null
}
export function getStorageBackendFromEnv(): {
type: StorageBackendType
config: Record<string, unknown>
secrets: { accessKeyId: string, secretAccessKey: string }
servingMode: ServingMode
publicUrl: string
} | null {
const backendType = process.env.STORAGE_BACKEND?.trim()
if (backendType !== 's3') return null
const endpoint = process.env.S3_ENDPOINT?.trim() ?? ''
const region = process.env.S3_REGION?.trim() ?? ''
const bucket = process.env.S3_BUCKET?.trim() ?? ''
const accessKeyId = process.env.S3_ACCESS_KEY?.trim() ?? ''
const secretAccessKey = process.env.S3_SECRET_KEY?.trim() ?? ''
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
return null
}
return {
type: 's3',
config: {
endpoint,
region: region || 'auto',
bucket,
prefix: process.env.S3_PREFIX?.trim() || undefined,
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true'
},
secrets: { accessKeyId, secretAccessKey },
servingMode: process.env.S3_PUBLIC_URL?.trim() ? 'public' : 'proxy',
publicUrl: process.env.S3_PUBLIC_URL?.trim() ?? ''
}
}
export function createBackendInstance(row: StorageBackendRow) {
if (row.type === 'local') {
return new LocalStorageBackend(
row.id,
row.serving_mode,
row.public_url
)
}
const config = parseS3Config(row.config_json)
const secrets = parseS3Secrets(row.secret_json)
if (!config || !secrets) {
throw new Error(`Invalid S3 configuration for backend ${row.id}`)
}
return new S3StorageBackend(
row.id,
config,
secrets,
row.serving_mode,
row.public_url
)
}
export function createBackendFromEnv() {
const env = getStorageBackendFromEnv()
if (!env) return null
return new S3StorageBackend(
S3_BACKEND_ID,
env.config as unknown as S3BackendConfig,
env.secrets,
env.servingMode,
env.publicUrl
)
}
export function getBackendUsageStats(backendId: string): { count: number, bytes: number } {
ensureStorageSchema()
const row = getDb().prepare(`
SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
FROM images
WHERE backend_id = ?
`).get(backendId) as { count: number, bytes: number }
return { count: row.count, bytes: row.bytes }
}
function getNextSortOrder(): number {
const row = getDb().prepare(`
SELECT COALESCE(MAX(sort_order), 0) AS max_order FROM storage_backends
`).get() as { max_order: number }
return row.max_order + 1
}
export function generateStorageBackendId(): string {
const bytes = new Uint8Array(4)
crypto.getRandomValues(bytes)
const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
return `s3-${suffix}`
}
export function insertStorageBackend(input: {
id?: string
name: string
config: S3BackendConfig
secrets: { accessKeyId: string, secretAccessKey: string }
servingMode?: ServingMode
publicUrl?: string
quotaBytes?: number | null
enabled?: boolean
isDefault?: boolean
}): string {
ensureDefaultBackends()
const id = input.id ?? generateStorageBackendId()
const sortOrder = getNextSortOrder()
getDb().prepare(`
INSERT INTO storage_backends
(id, name, type, config_json, secret_json, serving_mode, public_url,
quota_bytes, enabled, is_default, sort_order)
VALUES (?, ?, 's3', ?, ?, ?, ?, ?, ?, 0, ?)
`).run(
id,
input.name,
JSON.stringify(input.config),
JSON.stringify(input.secrets),
input.servingMode ?? 'proxy',
input.publicUrl ?? '',
input.quotaBytes ?? null,
input.enabled === false ? 0 : 1,
sortOrder
)
if (input.isDefault) {
setDefaultBackend(id)
}
return id
}
export function deleteStorageBackend(id: string): void {
if (id === LOCAL_BACKEND_ID) {
throw new Error('Cannot delete local backend')
}
const usage = getBackendUsageStats(id)
if (usage.count > 0) {
throw new Error('Cannot delete backend with stored images')
}
const row = getStorageBackendRow(id)
if (!row) {
throw new Error(`Storage backend not found: ${id}`)
}
if (row.is_default) {
throw new Error('Cannot delete the default backend')
}
getDb().prepare('DELETE FROM storage_backends WHERE id = ?').run(id)
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { buildBackendCapacity } from './storage-capacity'
describe('buildBackendCapacity', () => {
it('calculates quota-based capacity for s3 backends', async () => {
const quotaBytes = 10 * 1024 ** 3
const capacity = await buildBackendCapacity('s3-test', 's3', 72_390, quotaBytes)
expect(capacity.source).toBe('quota')
expect(capacity.totalBytes).toBe(quotaBytes)
expect(capacity.usedBytes).toBe(72_390)
expect(capacity.freeBytes).toBe(quotaBytes - 72_390)
expect(capacity.percent).toBeGreaterThanOrEqual(0)
})
it('allows usage percent to exceed 100 when over quota', async () => {
const quotaBytes = 1024
const capacity = await buildBackendCapacity('s3-test', 's3', 2048, quotaBytes)
expect(capacity.percent).toBe(200)
expect(capacity.freeBytes).toBe(0)
})
it('reads disk stats for local backend when available', async () => {
const capacity = await buildBackendCapacity('local', 'local', 1024, null)
expect(capacity.source).toBe('disk')
if (capacity.totalBytes != null) {
expect(capacity.totalBytes).toBeGreaterThan(0)
expect(capacity.freeBytes).not.toBeNull()
expect(capacity.percent).not.toBeNull()
} else {
expect(capacity.usedBytes).toBe(1024)
expect(capacity.freeBytes).toBeNull()
expect(capacity.percent).toBeNull()
}
})
})
+92
View File
@@ -0,0 +1,92 @@
import { statfs } from 'node:fs/promises'
import { getDataDir } from './data-dir'
import { LOCAL_BACKEND_ID } from './storage-backends'
export interface StorageCapacity {
totalBytes: number | null
usedBytes: number
freeBytes: number | null
percent: number | null
source: 'disk' | 'quota'
}
export function parseQuotaBytes(raw: unknown): number | null {
const value = Number(raw)
if (!Number.isFinite(value) || value <= 0) return null
return Math.round(value)
}
export async function getLocalVolumeStats(): Promise<{
totalBytes: number
freeBytes: number
usedBytes: number
} | null> {
try {
const stats = await statfs(getDataDir())
const totalBytes = Number(stats.bsize) * Number(stats.blocks)
const freeBytes = Number(stats.bsize) * Number(stats.bavail)
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null
return {
totalBytes,
freeBytes: Math.max(0, freeBytes),
usedBytes: Math.max(0, totalBytes - freeBytes)
}
} catch {
return null
}
}
function buildQuotaCapacity(quotaBytes: number, indexBytes: number): StorageCapacity {
const usedBytes = indexBytes
const freeBytes = Math.max(0, quotaBytes - usedBytes)
const percent = Math.round((usedBytes / quotaBytes) * 1000) / 10
return {
totalBytes: quotaBytes,
usedBytes,
freeBytes,
percent,
source: 'quota'
}
}
export async function buildBackendCapacity(
backendId: string,
backendType: 'local' | 's3',
indexBytes: number,
quotaBytes: number | null
): Promise<StorageCapacity> {
if (backendType === 's3') {
if (quotaBytes && quotaBytes > 0) {
return buildQuotaCapacity(quotaBytes, indexBytes)
}
return {
totalBytes: null,
usedBytes: indexBytes,
freeBytes: null,
percent: null,
source: 'quota'
}
}
if (backendId === LOCAL_BACKEND_ID || backendType === 'local') {
const volume = await getLocalVolumeStats()
if (volume) {
const percent = Math.round((volume.usedBytes / volume.totalBytes) * 1000) / 10
return {
totalBytes: volume.totalBytes,
usedBytes: volume.usedBytes,
freeBytes: volume.freeBytes,
percent,
source: 'disk'
}
}
}
return {
totalBytes: null,
usedBytes: indexBytes,
freeBytes: null,
percent: null,
source: 'disk'
}
}
+89 -330
View File
@@ -1,314 +1,130 @@
import { createReadStream, promises as fs } from 'node:fs'
import type { ReadStream } from 'node:fs'
import { dirname, join } from 'node:path'
import { isValidFolderName, validateImageKey, DEFAULT_FOLDER } from './image-key'
import type { Readable } from 'node:stream'
import {
countImages as countImagesFromIndex,
deleteImageIndex,
getFolderStorageStats as getFolderStorageStatsFromIndex,
getImageIndexAsStored,
getUserScopedStorageStats as getUserScopedStorageStatsFromIndex,
insertImageIndex,
listFolders as listFoldersFromIndex,
listFoldersForUser as listFoldersForUserFromIndex,
listImageKeysForAutoDelete,
listImages as listImagesFromIndex,
searchImages as searchImagesFromIndex
} from './image-index'
import {
getActiveBackend,
getBackendForKey
} from './storage/resolver'
import type {
FolderStorageStat,
PaginatedResult,
StoredImage,
StoredImageMeta
} from './storage/types'
/**
* 本地文件系统存储层
*
* 目录布局(DATA_DIR 默认 ./data,容器内挂载 /data):
* <DATA_DIR>/<type>/YYYY/MM/<随机ID>.webp 按上传 type 分目录(默认 images)
* <DATA_DIR>/twikoo/YYYY/MM/<随机ID>.webp twikoo 评论图片
* <DATA_DIR>/<type>/YYYY/MM/<随机ID>.webp.meta.json 元数据(原始文件名等)
*
* 列表按日期降序(新月份在前),meta 缺失时回退到文件属性。
*/
export type {
FolderStorageStat,
PaginatedResult,
StoredImage,
StoredImageMeta
} from './storage/types'
export interface StoredImageMeta {
originalName: string
uploadedAt: string
contentType: string
size: number
userId?: number | null
}
export interface StoredImage extends StoredImageMeta {
key: string
mtimeMs: number
}
const META_SUFFIX = '.meta.json'
export function getDataDir(): string {
return process.env.DATA_DIR || join(process.cwd(), 'data')
}
function keyToFilePath(key: string): string {
// key 均已通过 validateImageKey 校验,不存在路径穿越
return join(getDataDir(), ...key.split('/'))
}
function contentTypeFromKey(key: string): string {
const ext = key.split('.').pop()?.toLowerCase()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
case 'svg':
return 'image/svg+xml'
case 'ico':
return 'image/x-icon'
default:
return 'application/octet-stream'
}
}
async function readMeta(key: string): Promise<Partial<StoredImageMeta>> {
try {
const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
return JSON.parse(raw) as Partial<StoredImageMeta>
} catch {
return {}
}
}
export { getDataDir } from './data-dir'
export async function putImage(
key: string,
bytes: Uint8Array,
meta: StoredImageMeta
): Promise<void> {
const filePath = keyToFilePath(key)
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, bytes)
await fs.writeFile(filePath + META_SUFFIX, JSON.stringify(meta))
): Promise<string> {
const backend = await getActiveBackend()
await backend.put(key, bytes, meta)
insertImageIndex({
key,
backendId: backend.id,
userId: meta.userId,
originalName: meta.originalName,
contentType: meta.contentType,
size: meta.size,
uploadedAt: meta.uploadedAt
})
return backend.id
}
export async function headImage(key: string): Promise<StoredImage | null> {
if (!validateImageKey(key)) return null
const backend = await getBackendForKey(key)
const stored = await backend.head(key)
if (!stored) return null
let stat
try {
stat = await fs.stat(keyToFilePath(key))
} catch {
return null
const indexed = getImageIndexAsStored(key)
if (indexed) {
return {
...stored,
backendId: backend.id,
originalName: indexed.originalName,
contentType: indexed.contentType,
uploadedAt: indexed.uploadedAt,
userId: indexed.userId
}
}
if (!stat.isFile()) return null
const meta = await readMeta(key)
return {
key,
size: stat.size,
mtimeMs: stat.mtimeMs,
contentType: meta.contentType ?? contentTypeFromKey(key),
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
uploadedAt: meta.uploadedAt ?? stat.mtime.toISOString(),
userId: meta.userId ?? null
}
insertImageIndex({
key: stored.key,
backendId: backend.id,
userId: stored.userId,
originalName: stored.originalName,
contentType: stored.contentType,
size: stored.size,
uploadedAt: stored.uploadedAt
})
return { ...stored, backendId: backend.id }
}
export function createImageStream(
export async function createImageStream(
key: string,
range?: { start: number, end: number }
): ReadStream {
return createReadStream(keyToFilePath(key), range)
): Promise<ReadStream | Readable> {
const backend = await getBackendForKey(key)
return backend.createStream(key, range)
}
export async function deleteImage(key: string): Promise<void> {
const filePath = keyToFilePath(key)
await fs.unlink(filePath)
await fs.unlink(filePath + META_SUFFIX).catch(() => {
// meta 文件可能不存在(外部导入的历史图片)
})
}
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
const backend = await getBackendForKey(key)
await backend.delete(key)
deleteImageIndex(key)
}
export async function listFolders(): Promise<string[]> {
const names = await readDirNames(getDataDir())
const folders = names.filter(isValidFolderName)
if (!folders.includes(DEFAULT_FOLDER)) {
folders.push(DEFAULT_FOLDER)
}
return folders.sort((a, b) => a.localeCompare(b))
return listFoldersFromIndex()
}
/** 遍历各 type 目录下的 YYYY/MM,返回按日期降序的 key 列表(新月份在前) */
export async function listImageKeys(folder?: string): Promise<string[]> {
const keys: string[] = []
const prefixes = folder && isValidFolderName(folder)
? [folder]
: await listFolders()
for (const prefix of prefixes) {
const root = join(getDataDir(), prefix)
const years = await readDirNames(root)
for (const year of years) {
const months = await readDirNames(join(root, year))
for (const month of months) {
const files = await readFileNames(join(root, year, month))
for (const file of files) {
if (file.endsWith(META_SUFFIX)) continue
const key = `${prefix}/${year}/${month}/${file}`
if (validateImageKey(key)) keys.push(key)
}
}
}
}
// 按 YYYY/MM/文件名 降序排,使不同 type 按时间交错而不是分块
return keys.sort((a, b) => {
const aDate = a.slice(a.indexOf('/') + 1)
const bDate = b.slice(b.indexOf('/') + 1)
return bDate.localeCompare(aDate) || b.localeCompare(a)
})
}
async function readDirNames(path: string): Promise<string[]> {
try {
const entries = await fs.readdir(path, { withFileTypes: true })
return entries.filter(e => e.isDirectory()).map(e => e.name)
} catch {
return []
}
}
function matchesUserFilter(
meta: StoredImageMeta,
userFilter: number | 'admin'
): boolean {
if (userFilter === 'admin') return true
const ownerId = meta.userId ?? null
if (ownerId === null) return false
return ownerId === userFilter
}
async function filterKeysByUser(
keys: string[],
userFilter?: number | 'admin'
): Promise<string[]> {
if (userFilter === undefined || userFilter === 'admin') {
return keys
}
const filtered: string[] = []
for (const key of keys) {
const meta = await readMeta(key)
if (matchesUserFilter(meta as StoredImageMeta, userFilter)) {
filtered.push(key)
}
}
return filtered
}
async function readFileNames(path: string): Promise<string[]> {
try {
const entries = await fs.readdir(path, { withFileTypes: true })
return entries.filter(e => e.isFile()).map(e => e.name)
} catch {
return []
}
}
function paginateSlice<T>(
all: T[],
limit: number,
page?: number
): PaginatedResult<T> {
const total = all.length
const pageSize = limit
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
const requestedPage = Math.max(1, page ?? 1)
const safePage = Math.min(requestedPage, totalPages)
const start = (safePage - 1) * pageSize
const items = all.slice(start, start + pageSize)
return {
items,
total,
page: safePage,
pageSize,
totalPages
}
export async function listImageKeys(): Promise<string[]> {
const images = await listImageKeysForAutoDelete()
return images.map(image => image.key)
}
export async function countImages(
folder?: string,
userFilter?: number | 'admin'
userFilter?: number | 'admin',
backendId?: string
): Promise<number> {
let keys = await listImageKeys(folder)
keys = await filterKeysByUser(keys, userFilter)
return keys.length
return countImagesFromIndex(folder, userFilter, backendId)
}
export interface FolderStorageStat {
folder: string
count: number
bytes: number
}
/** 按顶层目录统计当前磁盘上的图片数量与体积 */
export async function getFolderStorageStats(
userFilter?: number | 'admin'
): Promise<FolderStorageStat[]> {
let keys = await listImageKeys()
keys = await filterKeysByUser(keys, userFilter)
const map = new Map<string, { count: number, bytes: number }>()
for (const key of keys) {
const folder = key.split('/')[0] ?? 'unknown'
const meta = await headImage(key)
if (!meta) continue
const current = map.get(folder) ?? { count: 0, bytes: 0 }
current.count += 1
current.bytes += meta.size
map.set(folder, current)
}
return [...map.entries()]
.map(([folder, stat]) => ({ folder, ...stat }))
.sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
return getFolderStorageStatsFromIndex(userFilter)
}
export async function listFoldersForUser(
userFilter?: number | 'admin'
): Promise<string[]> {
if (userFilter === undefined || userFilter === 'admin') {
return listFolders()
}
let keys = await listImageKeys()
keys = await filterKeysByUser(keys, userFilter)
const folders = new Set<string>()
for (const key of keys) {
const folder = key.split('/')[0]
if (folder && isValidFolderName(folder)) {
folders.add(folder)
}
}
if (!folders.size) {
folders.add(DEFAULT_FOLDER)
}
return [...folders].sort((a, b) => a.localeCompare(b))
return listFoldersForUserFromIndex(userFilter)
}
function startOfDayIsoInShanghai(now = new Date()): string {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
const d = shifted.getUTCDate()
return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
}
function startOfMonthIsoInShanghai(now = new Date()): string {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
}
/** 普通用户统计:基于本人图片 meta,不依赖全局 activity_logs */
export async function getUserScopedStorageStats(userId: number): Promise<{
storedCount: number
uploadBytesTotal: number
@@ -316,42 +132,7 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
uploadMonth: number
byFolder: FolderStorageStat[]
}> {
let keys = await listImageKeys()
keys = await filterKeysByUser(keys, userId)
const startOfDay = startOfDayIsoInShanghai()
const startOfMonth = startOfMonthIsoInShanghai()
const map = new Map<string, { count: number, bytes: number }>()
let uploadBytesTotal = 0
let uploadToday = 0
let uploadMonth = 0
for (const key of keys) {
const meta = await headImage(key)
if (!meta) continue
uploadBytesTotal += meta.size
if (meta.uploadedAt >= startOfDay) uploadToday += 1
if (meta.uploadedAt >= startOfMonth) uploadMonth += 1
const folder = key.split('/')[0] ?? 'unknown'
const current = map.get(folder) ?? { count: 0, bytes: 0 }
current.count += 1
current.bytes += meta.size
map.set(folder, current)
}
const byFolder = [...map.entries()]
.map(([folder, stat]) => ({ folder, ...stat }))
.sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
return {
storedCount: keys.length,
uploadBytesTotal,
uploadToday,
uploadMonth,
byFolder
}
return getUserScopedStorageStatsFromIndex(userId)
}
export async function listImages(options: {
@@ -359,17 +140,9 @@ export async function listImages(options: {
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
let keys = await listImageKeys(options.folder)
keys = await filterKeysByUser(keys, options.userFilter)
const sliced = paginateSlice(keys, options.limit, options.page)
const items = (await Promise.all(sliced.items.map(headImage)))
.filter((item): item is StoredImage => item !== null)
return {
...sliced,
items
}
return listImagesFromIndex(options)
}
export async function searchImages(options: {
@@ -378,21 +151,7 @@ export async function searchImages(options: {
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
const needle = options.query.trim().toLowerCase()
let keys = await listImageKeys(options.folder)
keys = await filterKeysByUser(keys, options.userFilter)
const matches: StoredImage[] = []
for (const key of keys) {
const keyMatches = key.toLowerCase().includes(needle)
const item = await headImage(key)
if (!item) continue
if (keyMatches || item.originalName.toLowerCase().includes(needle)) {
matches.push(item)
}
}
return paginateSlice(matches, options.limit, options.page)
return searchImagesFromIndex(options)
}
+3
View File
@@ -0,0 +1,3 @@
export { getActiveBackend, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver'
export { LocalStorageBackend } from './local'
export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3'
+116
View File
@@ -0,0 +1,116 @@
import { createReadStream, promises as fs } from 'node:fs'
import type { ReadStream } from 'node:fs'
import { dirname, join } from 'node:path'
import { getDataDir } from '../data-dir'
import { validateImageKey } from '../image-key'
import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
const META_SUFFIX = '.meta.json'
function keyToFilePath(key: string): string {
return join(getDataDir(), ...key.split('/'))
}
function contentTypeFromKey(key: string): string {
const ext = key.split('.').pop()?.toLowerCase()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
case 'svg':
return 'image/svg+xml'
case 'ico':
return 'image/x-icon'
default:
return 'application/octet-stream'
}
}
async function readMeta(key: string): Promise<Partial<StoredImageMeta>> {
try {
const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
return JSON.parse(raw) as Partial<StoredImageMeta>
} catch {
return {}
}
}
export class LocalStorageBackend implements StorageBackend {
readonly id: string
readonly type = 'local' as const
readonly servingMode: 'proxy' | 'public'
readonly publicUrl: string
constructor(
id = 'local',
servingMode: 'proxy' | 'public' = 'proxy',
publicUrl = ''
) {
this.id = id
this.servingMode = servingMode
this.publicUrl = publicUrl
}
async put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise<void> {
const filePath = keyToFilePath(key)
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, bytes)
await fs.writeFile(filePath + META_SUFFIX, JSON.stringify(meta))
}
async head(key: string): Promise<StoredImage | null> {
if (!validateImageKey(key)) return null
let stat
try {
stat = await fs.stat(keyToFilePath(key))
} catch {
return null
}
if (!stat.isFile()) return null
const meta = await readMeta(key)
return {
key,
size: stat.size,
mtimeMs: stat.mtimeMs,
contentType: meta.contentType ?? contentTypeFromKey(key),
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
uploadedAt: meta.uploadedAt ?? stat.mtime.toISOString(),
userId: meta.userId ?? null,
backendId: this.id
}
}
async delete(key: string): Promise<void> {
const filePath = keyToFilePath(key)
await fs.unlink(filePath)
await fs.unlink(filePath + META_SUFFIX).catch(() => {})
}
async createStream(
key: string,
range?: { start: number, end: number }
): Promise<ReadStream> {
return createReadStream(keyToFilePath(key), range)
}
async testConnection(): Promise<{ ok: boolean, message: string }> {
try {
await fs.access(getDataDir())
return { ok: true, message: '本地磁盘可访问' }
} catch {
return { ok: false, message: '无法访问数据目录' }
}
}
async getUsageStats(): Promise<{ count: number, bytes: number }> {
return { count: 0, bytes: 0 }
}
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { LOCAL_BACKEND_ID } from '../storage-backends'
describe('storage resolver constants', () => {
it('uses stable local backend id', () => {
expect(LOCAL_BACKEND_ID).toBe('local')
})
})
describe('default backend routing', () => {
it('returns local when no env override and local is default', async () => {
const original = process.env.STORAGE_BACKEND
delete process.env.STORAGE_BACKEND
const { setDefaultBackend, LOCAL_BACKEND_ID } = await import('../storage-backends')
setDefaultBackend(LOCAL_BACKEND_ID)
const { getActiveBackend } = await import('./resolver')
const backend = await getActiveBackend()
expect(backend.type).toBe('local')
if (original !== undefined) {
process.env.STORAGE_BACKEND = original
}
})
})
+115
View File
@@ -0,0 +1,115 @@
import {
createBackendFromEnv,
createBackendInstance,
getBackendIdForKey,
getDefaultBackendRow,
getStorageBackendRow,
LOCAL_BACKEND_ID,
ensureDefaultBackends
} from '../storage-backends'
import type { StorageBackend, StorageBackendRow } from './types'
export async function getActiveBackend(): Promise<StorageBackend> {
const fromEnv = createBackendFromEnv()
if (fromEnv) return fromEnv
ensureDefaultBackends()
const row = getDefaultBackendRow()
if (!row) {
return createBackendInstance({
id: LOCAL_BACKEND_ID,
name: '本地磁盘',
type: 'local',
config_json: '{}',
secret_json: '{}',
serving_mode: 'proxy',
public_url: '',
quota_bytes: null,
enabled: 1,
is_default: 1,
sort_order: 0
})
}
return createBackendInstance(row)
}
export async function getBackendForKey(key: string): Promise<StorageBackend> {
const fromEnv = createBackendFromEnv()
if (fromEnv) return fromEnv
ensureDefaultBackends()
const backendId = getBackendIdForKey(key) ?? LOCAL_BACKEND_ID
const row = getStorageBackendRow(backendId)
if (!row || !row.enabled) {
return createBackendInstance(fallbackLocalRow())
}
try {
return createBackendInstance(row)
} catch {
return createBackendInstance(fallbackLocalRow())
}
}
export function getActiveBackendRow(): StorageBackendRow | null {
const fromEnv = createBackendFromEnv()
if (fromEnv) {
return {
id: fromEnv.id,
name: 'S3 (环境变量)',
type: 's3',
config_json: '{}',
secret_json: '{}',
serving_mode: fromEnv.servingMode,
public_url: fromEnv.publicUrl,
quota_bytes: null,
enabled: 1,
is_default: 1,
sort_order: 0
}
}
ensureDefaultBackends()
return getDefaultBackendRow()
}
export async function getBackendRowForKey(key: string): Promise<StorageBackendRow> {
const fromEnv = createBackendFromEnv()
if (fromEnv) {
return {
id: fromEnv.id,
name: 'S3 (环境变量)',
type: 's3',
config_json: '{}',
secret_json: '{}',
serving_mode: fromEnv.servingMode,
public_url: fromEnv.publicUrl,
quota_bytes: null,
enabled: 1,
is_default: 1,
sort_order: 0
}
}
ensureDefaultBackends()
const backendId = getBackendIdForKey(key) ?? LOCAL_BACKEND_ID
return getStorageBackendRow(backendId) ?? fallbackLocalRow()
}
function fallbackLocalRow(): StorageBackendRow {
return {
id: LOCAL_BACKEND_ID,
name: '本地磁盘',
type: 'local',
config_json: '{}',
secret_json: '{}',
serving_mode: 'proxy',
public_url: '',
quota_bytes: null,
enabled: 1,
is_default: 1,
sort_order: 0
}
}
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more