feat: sharelink 资源下载管理插件 v1.1.0
- 下载资源管理与 /download/{slug} 下载链接
- 下载统计(次数/去重人数/明细/CSV 导出)
- 按资源邮箱验证(Halo 通知中心发信,互认评论插件已验证邮箱)
- 本地附件防直链(/upload/** 404)
- 文章引用扫描
- Console 前端:Vue3 + ui-plugin-bundler-kit
@@ -0,0 +1,26 @@
|
||||
# ---- Gradle ----
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# ---- 前端(ui) ----
|
||||
node_modules/
|
||||
ui/node_modules/
|
||||
# console 产物由 `pnpm -C ui build` 生成,勿提交
|
||||
src/main/resources/console/
|
||||
|
||||
# ---- IDE ----
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
bin/
|
||||
|
||||
# ---- 系统 ----
|
||||
.DS_Store
|
||||
|
||||
# ---- 本地临时 ----
|
||||
workplace/
|
||||
*.log
|
||||
@@ -0,0 +1,39 @@
|
||||
# AGENTS.md — sharelink(Halo 资源下载管理插件)
|
||||
|
||||
## 项目概述
|
||||
|
||||
Halo 2.x(>=2.22,目标实例 Halo Pro 2.25.4)插件。管理文章中的资源下载链接:按资源的下载统计、邮箱验证、防附件直链、文章引用扫描。邮箱验证体系复制改造自 `~/Documents/Code/Ai/blog-comment`(plugin-comment-widget fork 的 emailcode/guard 包)。
|
||||
|
||||
## 结构
|
||||
|
||||
- `src/main/java/run/halo/sharelink/`
|
||||
- `model/` 自定义 Extension(@GVK group `sharelink.halo.run`):DownloadResource(spec.slug 唯一索引)、DownloadRecord(spec.resourceSlug 索引)、VerifiedDownloader(name=SHA-256(归一化邮箱))
|
||||
- `emailcode/` 验证码:Guava 内存缓存、限流、NotificationCenter 发信(REASON_TYPE `sharelink-download-verification`,模板在 `resources/extensions/notification.yaml`)
|
||||
- `verify/` 公开端点 `api.sharelink.halo.run/v1alpha1`(email-verify/-/send|/-/check)+ VerifiedEmailService(本插件 VerifiedDownloader ∪ 评论插件 VerifiedCommenter,后者运行时 Unstructured fetch,缺席自动降级)
|
||||
- `download/` RouterFunction Bean:`GET /download/{slug}`(自包含 HTML 页)、`GET /download/{slug}/file`(一次性 token 核销 → 记录 → 流式下载);FileStreamer 本地附件直接读盘(`halo.work-dir`/attachments + `storage.halo.run/local-relative-path` 注解),外部存储回环 HTTP(带 `X-Sharelink-Internal` 头)
|
||||
- `protect/UploadProtectFilter` AdditionalWebFilter:GET/HEAD `/upload/**` 命中受保护附件 → 404
|
||||
- `reference/PostReferenceService` 扫描已发布文章 releaseSnapshot 内容匹配 `/download/{slug}`,5min 缓存
|
||||
- `console/ConsoleEndpoint` `console.api.sharelink.halo.run/v1alpha1`:资源 CRUD(body 为扁平 spec)、记录分页/删除/CSV、references 查询/刷新
|
||||
- `src/main/resources/extensions/` settings.yaml、notification.yaml、role-templates.yaml(匿名放行公开 API;console view/manage 角色,`ui-permissions: plugin:sharelink:view/manage`)
|
||||
- `ui/` console 前端:Vue 3 + @halo-dev/ui-shared + ui-plugin-bundler-kit(rsbuild),产物输出到 `src/main/resources/console/`(**构建前会覆盖该目录,勿手改**)。API 层 `ui/src/api/index.ts` 的 `normalizeResource()` 把后端扁平 ResourceVo 转成 Halo 风格结构
|
||||
|
||||
## 构建 / 部署
|
||||
|
||||
```bash
|
||||
pnpm -C ui install && pnpm -C ui build
|
||||
export JAVA_HOME=~/.gradle/jdks/jdk-21.0.12+8/Contents/Home # 本机无系统 JDK
|
||||
./gradlew build -x test # build/libs/sharelink-<version>.jar
|
||||
```
|
||||
|
||||
关键经验(踩过的坑):
|
||||
1. 改代码后发布必须递增 `gradle.properties` 的 version——console 静态资源按 `?version=` 缓存,不递增浏览器会用旧包。
|
||||
2. Console UI 升级插件会弹「插件已存在,是否升级?」确认框,不点确定不会真正替换 jar。
|
||||
3. 插件数据存 ExtensionStore(MySQL),卸载/重装插件不丢数据;但 Halo 的插件静态资源目录在重装时才重新解压。
|
||||
4. 计数在「token 核销 + 附件可流式输出」之后才写入,下载失败不计数;免验证资源按 slug+IP 内存窗口去重。
|
||||
5. UI 安装有两条路:①「远程下载」需要 Halo 服务器能反向访问本机 jar 服务(本机换网/IP 变化后会失败);②「本地上传」用 webbridge 时 CDP `setFileInputFiles` 被浏览器禁用,可用兜底方案:分块 base64 经 `evaluate` 推到页面 `window.__jarB64` → JS 构造 `File` + `DataTransfer` → 对 `.uppy-Dashboard-inner` 派发合成 `dragenter/dragover/drop` 事件触发 Uppy 上传 → 再点升级确认框「确定」。
|
||||
6. 下载页自包含 HTML 里若给元素自定义了 `display`(如 flex),必须补 `[hidden] { display: none !important; }`,否则 `hidden` 属性失效导致元素误显示。
|
||||
|
||||
## 测试
|
||||
|
||||
端到端验证依赖目标实例(http://192.168.3.2:8090)。公开 API 可用 curl 匿名测试:
|
||||
`POST /apis/api.sharelink.halo.run/v1alpha1/downloads/-/token {"slug":"..."}` → `GET /download/{slug}/file?token=...`。
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
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.
|
||||
|
||||
Preamble
|
||||
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
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>.
|
||||
@@ -0,0 +1,55 @@
|
||||
# sharelink — Halo 资源下载管理插件
|
||||
|
||||
管理文章中的资源下载链接:下载统计、按资源的邮箱验证、防止附件直链下载、文章引用扫描。
|
||||
|
||||
📖 **[详细配置指南与使用说明(含截图)](docs/使用指南.md)**
|
||||
|
||||
## 功能
|
||||
|
||||
- **下载资源管理**(Console「内容 → 下载管理」):选择附件创建下载资源,生成 `/download/{slug}` 下载链接,复制后粘贴到文章即可。
|
||||
- **下载统计**:每个资源的下载次数、去重下载人数、详细下载记录(时间/邮箱/IP/UA),支持 CSV 导出。
|
||||
- **邮箱验证**:按资源开关。访客输入邮箱 → 收验证码(走 Halo 通知中心,复用站点 SMTP)→ 验证后下载。已验证邮箱持久化,后续免验证;开启「信任评论插件已验证的邮箱」后,与评论组件(plugin-comment-widget fork)的已验证邮箱互认。
|
||||
- **防直链**:注册为资源的本地附件,其 `/upload/**` 直链对外返回 404;文件由插件从磁盘流式输出,URL 不暴露。
|
||||
- **文章引用扫描**:扫描已发布文章内容,展示每个资源被哪些文章引用(可跳转编辑/访问),5 分钟缓存 + 手动刷新。
|
||||
|
||||
## 工作原理(简)
|
||||
|
||||
- 访客流程:`/download/{slug}`(自包含 HTML 页)→(可选邮箱验证)→ POST `downloads/-/token` 换一次性 token(默认 60s 有效)→ `/download/{slug}/file?token=` 核销并流式下载、记录计数。
|
||||
- 防直链:`AdditionalWebFilter` 拦截 GET/HEAD `/upload/**`,命中受保护附件 permalink 集合返回 404。
|
||||
- 免验证资源:同一 IP 在去重窗口(默认 10 分钟)内重复下载只计 1 次。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
cd ui && pnpm install && pnpm build # 前端产物输出到 src/main/resources/console
|
||||
cd .. && ./gradlew build -x test # 产出 build/libs/sharelink-<version>.jar
|
||||
```
|
||||
|
||||
- 无本地 JDK 时:`settings.gradle` 已配 foojay-resolver 自动下载 JDK 21 到 `~/.gradle`。
|
||||
- 前端独立构建(不集成进 gradle),改前端后记得先 `pnpm -C ui build` 再打 jar。
|
||||
|
||||
## 部署 / 升级注意事项
|
||||
|
||||
1. Console「插件 → 安装 → 远程下载/本地上传」安装 jar。
|
||||
2. **升级已安装的插件时**,UI 会弹「插件已存在,是否升级?」确认框,必须点「确定」才会真正替换。
|
||||
3. **前端静态资源按 `?version=` 缓存**:发布新版本务必递增 `gradle.properties` 的 `version`,否则浏览器会沿用旧 console 包。
|
||||
4. 升级后如行为未变,停用再启用插件强制重启。
|
||||
|
||||
## 使用注意
|
||||
|
||||
- 不要把文章内需要直接显示的图片注册为下载资源(其 `/upload/` 直链会被 404,图片将无法显示)。
|
||||
- 外部对象存储(S3 等)附件无法拦截直链(防直链仅对本地存储策略生效);此类附件走回环 HTTP 转发下载。
|
||||
- 邮箱验证依赖「设置 → 通知设置」中已配置的邮件通知器。
|
||||
|
||||
## 设置项
|
||||
|
||||
| 组 | 项 | 默认 |
|
||||
|---|---|---|
|
||||
| 基本设置 | 下载令牌有效期(秒) | 60 |
|
||||
| 基本设置 | 免验证资源下载去重窗口(分钟) | 10 |
|
||||
| 邮箱验证 | 验证码有效期(分钟) | 10 |
|
||||
| 邮箱验证 | 重发间隔(秒) | 60 |
|
||||
| 邮箱验证 | 同一邮箱每日发送上限 | 5 |
|
||||
| 邮箱验证 | 验证码最大错误尝试次数 | 5 |
|
||||
| 邮箱验证 | 同一 IP 每小时发送上限 | 20 |
|
||||
| 邮箱验证 | 信任评论插件已验证的邮箱 | 开 |
|
||||
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id "io.freefair.lombok" version "8.14"
|
||||
id "run.halo.plugin.devtools" version "0.6.2"
|
||||
}
|
||||
|
||||
group 'run.halo.sharelink'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
options.release = 21
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation platform('run.halo.tools.platform:plugin:2.21.0')
|
||||
compileOnly 'run.halo.app:api'
|
||||
|
||||
implementation 'com.google.guava:guava:33.4.8-jre'
|
||||
|
||||
testImplementation 'run.halo.app:api'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// 前端单独构建:cd ui && pnpm install && pnpm build(产物输出到 src/main/resources/console)
|
||||
// 见 ui/README.md。不在 gradle 中集成 node,避免重复下载 node 运行时。
|
||||
|
||||
halo {
|
||||
version = "2.21.7"
|
||||
debug = true
|
||||
}
|
||||
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,257 @@
|
||||
# 资源下载管理(sharelink)插件 · 配置指南与使用说明
|
||||
|
||||
> 适用版本:sharelink 1.0.x / Halo ≥ 2.22(已在 Halo Pro 2.25.4 实测)
|
||||
>
|
||||
> 本文配合截图说明插件的功能、配置方法、使用流程和注意事项。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [插件是做什么的](#一插件是做什么的)
|
||||
2. [安装与启用](#二安装与启用)
|
||||
3. [全局配置](#三全局配置)
|
||||
4. [日常使用:创建下载资源](#四日常使用创建下载资源)
|
||||
5. [访客看到的下载页](#五访客看到的下载页)
|
||||
6. [下载统计与记录](#六下载统计与记录)
|
||||
7. [文章引用扫描](#七文章引用扫描)
|
||||
8. [邮箱验证详解](#八邮箱验证详解)
|
||||
9. [防直链原理与边界](#九防直链原理与边界)
|
||||
10. [注意事项汇总](#十注意事项汇总)
|
||||
11. [常见问题 FAQ](#十一常见问题-faq)
|
||||
|
||||
---
|
||||
|
||||
## 一、插件是做什么的
|
||||
|
||||
Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下载,无法统计、无法设防。本插件把「资源下载」变成可管理的一等公民:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|---|---|
|
||||
| **下载链接管理** | 为每个资源生成 `/download/{slug}` 链接,粘贴到文章即可 |
|
||||
| **下载统计** | 每个资源的下载次数、去重下载人数、逐条下载记录(时间/邮箱/IP/UA),可导出 CSV |
|
||||
| **邮箱验证** | 按资源开关。访客需输入邮箱收取验证码,验证通过才能下载;验证过的邮箱长期免验证 |
|
||||
| **防直链** | 注册为资源的附件,`/upload/**` 直链对外直接 404,文件真实地址不暴露 |
|
||||
| **文章引用扫描** | 一键扫描全站文章,告诉你每个下载资源被哪些文章引用 |
|
||||
|
||||
### 工作流程(一图流)
|
||||
|
||||
```
|
||||
文章中粘贴 /download/xxx 链接
|
||||
│
|
||||
▼
|
||||
访客打开下载页(插件生成的独立页面)
|
||||
│
|
||||
├─ 不需要验证:点击「立即下载」
|
||||
└─ 需要验证:输入邮箱 → 收验证码 → 填验证码
|
||||
│
|
||||
▼
|
||||
换取一次性下载令牌(默认 60 秒有效)→ 开始下载
|
||||
│
|
||||
▼
|
||||
后台记录一次下载(次数 +1,写入记录)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、安装与启用
|
||||
|
||||
1. 进入 **Console → 系统 → 插件**,点击右上角 **「安装」**。
|
||||
2. 选择 **「本地上传」** 上传 `sharelink-x.y.z.jar`(或「远程下载」粘贴 jar 地址)。
|
||||
3. 安装后在插件列表找到 **「资源下载管理」**,点击启用。
|
||||
4. 启用成功后,左侧菜单 **「内容」** 分组下会出现 **「下载管理」** 入口。
|
||||
|
||||
> ⚠️ **升级插件时的两个坑**(Halo 通用行为,非本插件问题):
|
||||
> - 用同名单 jar 覆盖安装时,会弹出「插件已存在,是否升级?」确认框,**必须点「确定」** 才会真正替换。
|
||||
> - 后台界面静态资源按 `?version=` 缓存。**每次发布新版本必须递增版本号**,否则浏览器会继续使用旧界面。
|
||||
|
||||
---
|
||||
|
||||
## 三、全局配置
|
||||
|
||||
进入 **系统 → 插件 → 资源下载管理 → 设置**,有两个标签页。
|
||||
|
||||
### 3.1 基本设置
|
||||
|
||||

|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| **下载令牌有效期(秒)** | 60 | 访客在下载页点击下载后,插件签发一次性下载令牌,需在此时长内开始下载。过期或已使用的令牌会跳回下载页重新获取,防止下载地址被传播复用 |
|
||||
| **免验证资源下载去重窗口(分钟)** | 10 | 不需要邮箱验证的资源,同一 IP 在窗口内重复下载只计 1 次(防止刷新刷量),但下载行为本身不受限 |
|
||||
|
||||
### 3.2 邮箱验证
|
||||
|
||||

|
||||
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| **验证码有效期(分钟)** | 10 | 验证码超过该时长未使用即失效 |
|
||||
| **重发间隔(秒)** | 60 | 同一邮箱两次发码的最小间隔(下载页按钮有倒计时) |
|
||||
| **同一邮箱每日发送上限** | 5 | 防止针对单个邮箱的轰炸 |
|
||||
| **验证码最大错误尝试次数** | 5 | 连续输错超过该次数,验证码作废,需重新获取 |
|
||||
| **同一 IP 每小时发送上限** | 20 | 防止单个 IP 批量发码 |
|
||||
| **信任评论插件已验证的邮箱** | 开启 | 开启后,在评论组件中已验证过邮箱的访客,下载需要验证的资源时**无需再次验证**(需安装评论组件插件) |
|
||||
|
||||
> ⚠️ 邮箱验证依赖 Halo 的邮件通知能力。请确认 **设置 → 通知设置** 中已配置可用的邮件通知器(SMTP),否则验证码邮件发不出去。你的站点配置评论插件时应该已经配好。
|
||||
|
||||
---
|
||||
|
||||
## 四、日常使用:创建下载资源
|
||||
|
||||
进入 **内容 → 下载管理**,这里是所有下载资源的统一管理中心。
|
||||
|
||||

|
||||
|
||||
### 4.1 新建资源
|
||||
|
||||
点击右上角 **「新建资源」**:
|
||||
|
||||

|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| **资源名称** * | 显示给访客的名称,也是下载文件的文件名(无扩展名时自动补上附件原扩展名) |
|
||||
| **slug** * | 下载链接的标识,如填 `whitepaper-2024`,下载链接就是 `/download/whitepaper-2024`。仅限小写字母、数字、中划线。**创建后不可修改** |
|
||||
| **描述** | 可选,显示在下载页标题下方 |
|
||||
| **附件** | 点击「选择附件」从附件库选择(也可先上传新附件) |
|
||||
| **需要邮箱验证** | 开启后访客必须验证邮箱才能下载 |
|
||||
| **启用** | 停用后下载页和下载链接立即 404,但配置保留 |
|
||||
|
||||
保存后回到列表,点击该行的 **「复制」** 按钮即可拿到完整下载链接(含域名),粘贴到文章的任意位置(普通链接、按钮、卡片都可以)。
|
||||
|
||||
### 4.2 列表各列含义
|
||||
|
||||
- **邮箱验证**:该资源是否需要验证(需要 / 不需要)
|
||||
- **启用**:开关即改即存
|
||||
- **引用文章**:引用该资源下载链接的文章数,点击展开详情(见第七节)
|
||||
- **下载数 / 下载人数**:累计下载次数 / 去重后的下载人数(验证资源按邮箱去重,免验证资源按 IP 去重)
|
||||
- **操作**:记录(下载记录)、编辑、删除
|
||||
|
||||
> ⚠️ 删除资源会**连同其全部下载记录一起删除**,且下载链接立即失效,请谨慎操作。
|
||||
|
||||
---
|
||||
|
||||
## 五、访客看到的下载页
|
||||
|
||||
### 5.1 免验证资源
|
||||
|
||||

|
||||
|
||||
页面展示资源名称和描述,点击 **「立即下载」** 即开始下载。简单直接。
|
||||
|
||||
### 5.2 需要邮箱验证的资源
|
||||
|
||||

|
||||
|
||||
1. 输入邮箱地址;
|
||||
2. 点击 **「发送验证码」**,按钮进入 60 秒倒计时,页面提示「验证码已发送,请查收邮件(10 分钟内有效)」;
|
||||
3. 将邮件中的 6 位验证码填入,点击 **「立即下载」**。
|
||||
|
||||
**已验证过的邮箱**:下次再访问任何需要验证的资源时,输入邮箱后页面会提示「该邮箱已完成验证,可直接下载」,无需验证码(包括在评论区验证过的邮箱):
|
||||
|
||||

|
||||
|
||||
> 下载页为插件自带的独立页面,不依赖主题,任何主题下表现一致。
|
||||
|
||||
---
|
||||
|
||||
## 六、下载统计与记录
|
||||
|
||||
在资源列表点击 **「记录」**,打开该资源的下载记录:
|
||||
|
||||

|
||||
|
||||
- 每条记录包含:**时间、邮箱**(免验证资源显示「匿名」)、**IP、User-Agent**;
|
||||
- 支持分页浏览、删除单条记录;
|
||||
- 点击 **「导出 CSV」** 可下载全部记录(带 BOM,Excel 直接打开不乱码)。
|
||||
|
||||
需要邮箱验证的资源,记录中可以看到具体是哪个邮箱下载的:
|
||||
|
||||

|
||||
|
||||
**计数口径**(重要,避免误解数字):
|
||||
|
||||
- 只有**真正开始下载**才计数:令牌核销成功且文件可输出时记 1 次,下载页浏览不计数、失败不计数;
|
||||
- 免验证资源:同一 IP 在去重窗口(默认 10 分钟)内重复下载只计 1 次;
|
||||
- 需要验证的资源:每次换令牌下载都计数(同一人多次下载会体现为次数 > 人数)。
|
||||
|
||||
---
|
||||
|
||||
## 七、文章引用扫描
|
||||
|
||||
资源多了之后最容易遇到的问题:「这个资源到底在哪些文章里用过?删了会不会有文章变死链?」
|
||||
|
||||
列表的 **「引用文章」** 列给出答案。点击数字展开:
|
||||
|
||||

|
||||
|
||||
- 显示每篇引用文章的标题,点击 **「编辑器」** 直接跳转到文章编辑页,**「访问」** 打开前台文章页;
|
||||
- 扫描结果缓存 5 分钟。刚改完文章想立即看到最新结果,点击页面顶部的 **「刷新引用扫描」** 强制重扫。
|
||||
|
||||
**扫描口径**:只统计**已发布**文章的正式内容中出现的 `/download/{slug}`;草稿、回收站文章、历史快照不计入。
|
||||
|
||||
---
|
||||
|
||||
## 八、邮箱验证详解
|
||||
|
||||
### 8.1 验证状态的来龙去脉
|
||||
|
||||
- 验证通过后,邮箱会被**永久登记**为「已验证」,之后下载任何需要验证的资源都无需再验证;
|
||||
- 「已验证邮箱」有两个来源(可在设置中关掉第二个):
|
||||
1. **本插件验证过的**:在下载页完成验证码验证的邮箱;
|
||||
2. **评论插件验证过的**(互认):评论组件中「已验证邮箱」名单里的访客。开启「信任评论插件已验证的邮箱」后,在评论区验证过的读者下载时直接免验证,体验无缝。
|
||||
|
||||
### 8.2 安全设计(了解即可)
|
||||
|
||||
- 验证码 6 位数字,10 分钟有效,一次性使用,连续输错 5 次作废;
|
||||
- 验证码比对使用恒时比较,防时序攻击;
|
||||
- 发码有三级限流:同邮箱 60 秒重发间隔、同邮箱每天 5 封、同 IP 每小时 20 封;
|
||||
- 邮件通过 Halo 通知中心发出,复用站点已有 SMTP 配置,插件不直接接触邮件密码。
|
||||
|
||||
---
|
||||
|
||||
## 九、防直链原理与边界
|
||||
|
||||
**原理**:插件在请求层面拦截 `GET/HEAD /upload/**`,如果目标附件已被注册为「启用的下载资源」,对外直接返回 404(就像文件不存在一样);访客只能通过 `/download/{slug}` 下载页获取文件——文件由插件直接从服务器磁盘流式输出,**真实存储路径全程不暴露**。
|
||||
|
||||
**边界(务必了解)**:
|
||||
|
||||
| 场景 | 是否生效 |
|
||||
|---|---|
|
||||
| 本地存储策略的附件(你当前的使用方式) | ✅ 直链 404 |
|
||||
| 外部对象存储(S3/OSS 等)的附件 | ❌ 无法拦截(文件不经 Halo 发出),但下载统计和邮箱验证仍正常 |
|
||||
| 同一附件被文章当图片直接引用 | ⚠️ 会被一并拦截,图片变 404! |
|
||||
|
||||
> ⚠️ **最重要的使用纪律**:不要把文章中需要**直接显示**的图片/附件注册为下载资源。注册即等于"此文件只能经下载页获取"。
|
||||
|
||||
---
|
||||
|
||||
## 十、注意事项汇总
|
||||
|
||||
1. **图片与下载资源分离**:需要直接展示的图片不要注册为资源;建议下载类文件(zip/pdf 等)单独建一个附件分组管理。
|
||||
2. **slug 创建后不可改**:改 slug 等于换链接,旧链接立即失效。如确需更换,新建资源并在文章里换链接(可用引用扫描找出所有旧链接位置)。
|
||||
3. **删除资源会级联删除下载记录**。
|
||||
4. **令牌有效期短是特性**:把 `/download/xxx/file?token=...` 发给别人是无用的(60 秒 + 一次性),分享请分享 `/download/xxx` 页面链接。
|
||||
5. **大文件**:文件经 Halo 应用中转流式输出,不占内存,但占用服务器带宽;GB 级大文件建议评估带宽。
|
||||
6. **引用扫描是按需的**:不实时监听文章变更,改完文章点「刷新引用扫描」或等 5 分钟缓存过期。
|
||||
7. **升级插件**:确认弹窗要点「确定」;版本号每次递增;升级后界面异常先强刷浏览器。
|
||||
|
||||
---
|
||||
|
||||
## 十一、常见问题 FAQ
|
||||
|
||||
**Q:访客点下载没反应?**
|
||||
A:下载令牌默认 60 秒有效,网络慢导致跳转超时时会自动回到下载页,重新点击即可。
|
||||
|
||||
**Q:收不到验证码邮件?**
|
||||
A:① 检查 设置 → 通知设置 的 SMTP 是否可用(评论验证能用即正常);② 检查垃圾邮件;③ 同一邮箱每天最多发 5 封、同一 IP 每小时 20 封,超限会提示稍后再试。
|
||||
|
||||
**Q:为什么图片附件注册成资源后文章里图片裂了?**
|
||||
A:这是防直链的预期行为。把该图片从资源中移除(删除资源或换用专用下载文件),图片即可恢复显示。
|
||||
|
||||
**Q:下载数为什么比下载人数多?**
|
||||
A:正常。同一个人多次下载,次数累加、人数去重。
|
||||
|
||||
**Q:对象存储附件能防直链吗?**
|
||||
A:不能。防直链只对本地存储策略生效;但统计与邮箱验证对所有存储策略都有效。
|
||||
@@ -0,0 +1 @@
|
||||
version=1.1.0
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.0.0-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,5 @@
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
|
||||
}
|
||||
|
||||
rootProject.name = 'sharelink'
|
||||
@@ -0,0 +1,53 @@
|
||||
package run.halo.sharelink;
|
||||
|
||||
import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import run.halo.app.extension.Scheme;
|
||||
import run.halo.app.extension.SchemeManager;
|
||||
import run.halo.app.extension.index.IndexSpec;
|
||||
import run.halo.app.plugin.BasePlugin;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
import run.halo.sharelink.model.DownloadRecord;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
import run.halo.sharelink.model.VerifiedDownloader;
|
||||
|
||||
@Component
|
||||
public class SharelinkPlugin extends BasePlugin {
|
||||
|
||||
private final SchemeManager schemeManager;
|
||||
|
||||
public SharelinkPlugin(PluginContext pluginContext, SchemeManager schemeManager) {
|
||||
super(pluginContext);
|
||||
this.schemeManager = schemeManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
schemeManager.register(DownloadResource.class, indexSpecs -> indexSpecs.add(
|
||||
new IndexSpec()
|
||||
.setName("spec.slug")
|
||||
.setUnique(true)
|
||||
.setIndexFunc(simpleAttribute(DownloadResource.class,
|
||||
resource -> resource.getSpec() == null ? null : resource.getSpec().getSlug()))));
|
||||
schemeManager.register(DownloadRecord.class, indexSpecs -> {
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName("spec.resourceSlug")
|
||||
.setIndexFunc(simpleAttribute(DownloadRecord.class,
|
||||
record -> record.getSpec() == null ? null : record.getSpec().getResourceSlug())));
|
||||
indexSpecs.add(new IndexSpec()
|
||||
.setName("spec.downloadedAt")
|
||||
.setIndexFunc(simpleAttribute(DownloadRecord.class,
|
||||
record -> record.getSpec() == null || record.getSpec().getDownloadedAt() == null
|
||||
? null : record.getSpec().getDownloadedAt().toString())));
|
||||
});
|
||||
schemeManager.register(VerifiedDownloader.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
schemeManager.unregister(Scheme.buildFromType(DownloadResource.class));
|
||||
schemeManager.unregister(Scheme.buildFromType(DownloadRecord.class));
|
||||
schemeManager.unregister(Scheme.buildFromType(VerifiedDownloader.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package run.halo.sharelink;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.plugin.ReactiveSettingFetcher;
|
||||
|
||||
/**
|
||||
* Typed access to this plugin's setting groups ({@code basic} and {@code emailVerify}).
|
||||
* Every getter falls back to built-in defaults and never returns {@link Mono#empty()}.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SharelinkSettingConfigGetter {
|
||||
|
||||
private final ReactiveSettingFetcher settingFetcher;
|
||||
|
||||
public Mono<BasicConfig> getBasicConfig() {
|
||||
return settingFetcher.fetch(BasicConfig.GROUP, BasicConfig.class)
|
||||
.defaultIfEmpty(new BasicConfig());
|
||||
}
|
||||
|
||||
public Mono<EmailVerifyConfig> getEmailVerifyConfig() {
|
||||
return settingFetcher.fetch(EmailVerifyConfig.GROUP, EmailVerifyConfig.class)
|
||||
.defaultIfEmpty(new EmailVerifyConfig());
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class BasicConfig {
|
||||
public static final String GROUP = "basic";
|
||||
|
||||
private int tokenTtlSeconds = 60;
|
||||
|
||||
private int dedupeWindowMinutes = 10;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class EmailVerifyConfig {
|
||||
public static final String GROUP = "emailVerify";
|
||||
|
||||
private int codeExpireMinutes = 10;
|
||||
|
||||
private int resendIntervalSeconds = 60;
|
||||
|
||||
private int dailySendLimitPerEmail = 5;
|
||||
|
||||
private int maxVerifyAttempts = 5;
|
||||
|
||||
private int ipHourlySendLimit = 20;
|
||||
|
||||
private boolean trustCommentVerified = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package run.halo.sharelink.console;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.PageRequestImpl;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.sharelink.model.DownloadRecord;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
import run.halo.sharelink.protect.UploadProtectFilter;
|
||||
import run.halo.sharelink.reference.PostReferenceService;
|
||||
|
||||
/**
|
||||
* Console endpoints for download resource administration, mounted under
|
||||
* {@code /apis/console.api.sharelink.halo.run/v1alpha1}, so authentication and RBAC are
|
||||
* enforced by Halo.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ConsoleEndpoint implements CustomEndpoint {
|
||||
|
||||
private static final Pattern SLUG_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,63}");
|
||||
private static final int STATS_CONCURRENCY = 8;
|
||||
private static final int MAX_PAGE_SIZE = 200;
|
||||
private static final MediaType CSV_MEDIA_TYPE =
|
||||
MediaType.parseMediaType("text/csv; charset=UTF-8");
|
||||
private static final String CSV_DISPOSITION =
|
||||
"attachment; filename=\"download-records.csv\"";
|
||||
/**
|
||||
* BOM prefix so spreadsheet applications detect the UTF-8 encoding.
|
||||
*/
|
||||
private static final String CSV_BOM = "\uFEFF";
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final PostReferenceService referenceService;
|
||||
private final UploadProtectFilter uploadProtectFilter;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return RouterFunctions.route()
|
||||
.GET("download-resources", this::listResources)
|
||||
.POST("download-resources", this::createResource)
|
||||
.GET("download-resources/{name}", this::getResource)
|
||||
.PUT("download-resources/{name}", this::updateResource)
|
||||
.DELETE("download-resources/{name}", this::deleteResource)
|
||||
.GET("download-records", this::listRecords)
|
||||
.DELETE("download-records/{name}", this::deleteRecord)
|
||||
.GET("download-records/-/export", this::exportRecords)
|
||||
.GET("references", this::getReferences)
|
||||
.POST("references/-/refresh", this::refreshReferences)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ---------- resources ----------
|
||||
|
||||
private Mono<ServerResponse> listResources(ServerRequest request) {
|
||||
var references = referenceService.references()
|
||||
.onErrorResume(e -> Mono.just(Map.<String, List<PostReferenceService.PostRef>>of()));
|
||||
return references.flatMap(refMap -> client.listAll(DownloadResource.class,
|
||||
new ListOptions(), Sort.unsorted())
|
||||
.sort(Comparator.comparing(ConsoleEndpoint::creationTimestamp,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.flatMap(resource -> toVo(resource, refMap), STATS_CONCURRENCY)
|
||||
.collectList())
|
||||
.flatMap(vos -> ServerResponse.ok().bodyValue(vos));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> createResource(ServerRequest request) {
|
||||
return request.bodyToMono(ResourceRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> validateResourceRequest(body, null)
|
||||
.then(Mono.defer(() -> {
|
||||
var resource = new DownloadResource();
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(UUID.randomUUID().toString());
|
||||
resource.setMetadata(metadata);
|
||||
var spec = new DownloadResource.Spec();
|
||||
applyRequest(spec, body);
|
||||
resource.setSpec(spec);
|
||||
var status = new DownloadResource.Status();
|
||||
status.setDownloadCount(0);
|
||||
resource.setStatus(status);
|
||||
return client.create(resource);
|
||||
})))
|
||||
.doOnSuccess(created -> uploadProtectFilter.invalidate())
|
||||
.flatMap(this::toVo)
|
||||
.flatMap(vo -> ServerResponse.ok().bodyValue(vo));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> getResource(ServerRequest request) {
|
||||
return client.fetch(DownloadResource.class, request.pathVariable("name"))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载资源不存在")))
|
||||
.flatMap(this::toVo)
|
||||
.flatMap(vo -> ServerResponse.ok().bodyValue(vo));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> updateResource(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return request.bodyToMono(ResourceRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> client.fetch(DownloadResource.class, name)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载资源不存在")))
|
||||
.flatMap(resource -> validateResourceRequest(body, name)
|
||||
.then(Mono.defer(() -> {
|
||||
applyRequest(resource.getSpec(), body);
|
||||
return client.update(resource);
|
||||
}))))
|
||||
.doOnSuccess(updated -> uploadProtectFilter.invalidate())
|
||||
.flatMap(this::toVo)
|
||||
.flatMap(vo -> ServerResponse.ok().bodyValue(vo));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> deleteResource(ServerRequest request) {
|
||||
var name = request.pathVariable("name");
|
||||
return client.fetch(DownloadResource.class, name)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载资源不存在")))
|
||||
.flatMap(resource -> {
|
||||
var slug = resource.getSpec().getSlug();
|
||||
return client.delete(resource)
|
||||
.then(deleteRecordsOfSlug(slug));
|
||||
})
|
||||
.doOnSuccess(ignored -> uploadProtectFilter.invalidate())
|
||||
.then(ServerResponse.ok().build());
|
||||
}
|
||||
|
||||
private Mono<Void> deleteRecordsOfSlug(String slug) {
|
||||
if (StringUtils.isBlank(slug)) {
|
||||
return Mono.empty();
|
||||
}
|
||||
return recordsOfSlug(slug)
|
||||
.flatMap(client::delete)
|
||||
.then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates slug format/uniqueness and attachment existence.
|
||||
*
|
||||
* @param selfName the name of the resource being updated, excluded from the
|
||||
* uniqueness check; {@code null} on creation
|
||||
*/
|
||||
private Mono<Void> validateResourceRequest(ResourceRequest body, String selfName) {
|
||||
var slug = StringUtils.trimToNull(body.slug());
|
||||
if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) {
|
||||
return Mono.error(new ServerWebInputException(
|
||||
"slug 格式不正确,需匹配 [a-z0-9][a-z0-9-]{0,63}"));
|
||||
}
|
||||
if (StringUtils.isBlank(body.attachmentName())) {
|
||||
return Mono.error(new ServerWebInputException("附件不能为空"));
|
||||
}
|
||||
var slugAvailable = findBySlug(slug)
|
||||
.filter(existing -> selfName == null
|
||||
|| !selfName.equals(existing.getMetadata().getName()))
|
||||
.flatMap(existing -> Mono.error(new ResponseStatusException(
|
||||
HttpStatus.CONFLICT, "slug 已被其他资源占用")))
|
||||
.then();
|
||||
var attachmentExists = client.fetch(Attachment.class, body.attachmentName())
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("附件不存在")))
|
||||
.then();
|
||||
return Mono.when(slugAvailable, attachmentExists);
|
||||
}
|
||||
|
||||
private static void applyRequest(DownloadResource.Spec spec, ResourceRequest body) {
|
||||
spec.setSlug(body.slug().trim());
|
||||
spec.setDisplayName(StringUtils.defaultIfBlank(
|
||||
StringUtils.trimToNull(body.displayName()), spec.getSlug()));
|
||||
spec.setDescription(StringUtils.trimToNull(body.description()));
|
||||
spec.setAttachmentName(body.attachmentName().trim());
|
||||
spec.setRequireEmailVerify(body.requireEmailVerify());
|
||||
spec.setEnabled(body.enabled());
|
||||
}
|
||||
|
||||
// ---------- records ----------
|
||||
|
||||
private Mono<ServerResponse> listRecords(ServerRequest request) {
|
||||
var resourceSlug = request.queryParam("resourceSlug")
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
var page = parsePositiveInt(request, "page", 1);
|
||||
var size = Math.min(parsePositiveInt(request, "size", 20), MAX_PAGE_SIZE);
|
||||
var optionsBuilder = ListOptions.builder();
|
||||
if (resourceSlug != null) {
|
||||
optionsBuilder.fieldQuery(QueryFactory.equal("spec.resourceSlug", resourceSlug));
|
||||
}
|
||||
var pageRequest = PageRequestImpl.of(page, size,
|
||||
Sort.by(Sort.Direction.DESC, "spec.downloadedAt"));
|
||||
return client.listBy(DownloadRecord.class, optionsBuilder.build(), pageRequest)
|
||||
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> deleteRecord(ServerRequest request) {
|
||||
return client.fetch(DownloadRecord.class, request.pathVariable("name"))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载记录不存在")))
|
||||
.flatMap(client::delete)
|
||||
.then(ServerResponse.ok().build());
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> exportRecords(ServerRequest request) {
|
||||
var resourceSlug = request.queryParam("resourceSlug")
|
||||
.map(StringUtils::trimToNull)
|
||||
.orElse(null);
|
||||
var records = resourceSlug == null
|
||||
? client.listAll(DownloadRecord.class, new ListOptions(), Sort.unsorted())
|
||||
: recordsOfSlug(resourceSlug);
|
||||
return records
|
||||
.sort(Comparator.comparing(ConsoleEndpoint::downloadedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.collectList()
|
||||
.map(ConsoleEndpoint::toCsv)
|
||||
.flatMap(csv -> ServerResponse.ok()
|
||||
.contentType(CSV_MEDIA_TYPE)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, CSV_DISPOSITION)
|
||||
.bodyValue(csv.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
// ---------- references ----------
|
||||
|
||||
private Mono<ServerResponse> getReferences(ServerRequest request) {
|
||||
return referenceService.references()
|
||||
.flatMap(refs -> ServerResponse.ok().bodyValue(refs));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> refreshReferences(ServerRequest request) {
|
||||
return referenceService.refresh()
|
||||
.flatMap(refs -> ServerResponse.ok().bodyValue(refs));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private Mono<ResourceVo> toVo(DownloadResource resource) {
|
||||
return referenceService.references()
|
||||
.onErrorResume(e -> Mono.just(Map.of()))
|
||||
.flatMap(refMap -> toVo(resource, refMap));
|
||||
}
|
||||
|
||||
private Mono<ResourceVo> toVo(DownloadResource resource,
|
||||
Map<String, List<PostReferenceService.PostRef>> refMap) {
|
||||
var spec = resource.getSpec();
|
||||
var slug = spec == null ? null : spec.getSlug();
|
||||
var records = StringUtils.isBlank(slug)
|
||||
? Mono.just(List.<DownloadRecord>of())
|
||||
: recordsOfSlug(slug).collectList();
|
||||
return records.map(recordList -> {
|
||||
var identities = new LinkedHashSet<String>();
|
||||
for (var record : recordList) {
|
||||
var recordSpec = record.getSpec();
|
||||
if (recordSpec == null) {
|
||||
continue;
|
||||
}
|
||||
if (StringUtils.isNotBlank(recordSpec.getEmail())) {
|
||||
identities.add("e:" + recordSpec.getEmail());
|
||||
} else if (StringUtils.isNotBlank(recordSpec.getIp())) {
|
||||
identities.add("i:" + recordSpec.getIp());
|
||||
}
|
||||
}
|
||||
var referenceCount = slug == null ? 0
|
||||
: refMap.getOrDefault(slug, List.of()).size();
|
||||
var stats = new ResourceStats(recordList.size(), identities.size(),
|
||||
referenceCount);
|
||||
return ResourceVo.from(resource, stats);
|
||||
});
|
||||
}
|
||||
|
||||
private Flux<DownloadRecord> recordsOfSlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.resourceSlug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadRecord.class, options, Sort.unsorted());
|
||||
}
|
||||
|
||||
private Mono<DownloadResource> findBySlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadResource.class, options, Sort.unsorted()).next();
|
||||
}
|
||||
|
||||
private static int parsePositiveInt(ServerRequest request, String name, int fallback) {
|
||||
return request.queryParam(name)
|
||||
.map(value -> {
|
||||
try {
|
||||
return Math.max(1, Integer.parseInt(value.trim()));
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
})
|
||||
.orElse(fallback);
|
||||
}
|
||||
|
||||
private static Instant creationTimestamp(DownloadResource resource) {
|
||||
return resource.getMetadata() == null ? null
|
||||
: resource.getMetadata().getCreationTimestamp();
|
||||
}
|
||||
|
||||
private static Instant downloadedAt(DownloadRecord record) {
|
||||
return record.getSpec() == null ? null : record.getSpec().getDownloadedAt();
|
||||
}
|
||||
|
||||
private static String toCsv(List<DownloadRecord> records) {
|
||||
var csv = new StringBuilder(CSV_BOM);
|
||||
csv.append("下载时间,资源Slug,邮箱,IP,User-Agent");
|
||||
for (var record : records) {
|
||||
var spec = record.getSpec();
|
||||
csv.append('\n')
|
||||
.append(csvField(instantText(downloadedAt(record)))).append(',')
|
||||
.append(csvField(spec == null ? null : spec.getResourceSlug())).append(',')
|
||||
.append(csvField(spec == null ? null : spec.getEmail())).append(',')
|
||||
.append(csvField(spec == null ? null : spec.getIp())).append(',')
|
||||
.append(csvField(spec == null ? null : spec.getUserAgent()));
|
||||
}
|
||||
return csv.toString();
|
||||
}
|
||||
|
||||
private static String instantText(Instant instant) {
|
||||
return instant == null ? "" : instant.toString();
|
||||
}
|
||||
|
||||
private static String csvField(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (value.contains(",") || value.contains("\"") || value.contains("\n")
|
||||
|| value.contains("\r")) {
|
||||
return '"' + value.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion("console.api.sharelink.halo.run/v1alpha1");
|
||||
}
|
||||
|
||||
public record ResourceRequest(String slug, String displayName, String description,
|
||||
String attachmentName, boolean requireEmailVerify,
|
||||
boolean enabled) {
|
||||
}
|
||||
|
||||
public record ResourceStats(long downloadCount, long downloaderCount,
|
||||
long referenceCount) {
|
||||
}
|
||||
|
||||
public record ResourceVo(String name, String slug, String displayName,
|
||||
String description, String attachmentName,
|
||||
boolean requireEmailVerify, boolean enabled,
|
||||
String downloadUrl, Instant creationTimestamp,
|
||||
ResourceStats stats) {
|
||||
|
||||
static ResourceVo from(DownloadResource resource, ResourceStats stats) {
|
||||
var spec = resource.getSpec();
|
||||
var name = resource.getMetadata() == null ? null
|
||||
: resource.getMetadata().getName();
|
||||
var creationTimestamp = ConsoleEndpoint.creationTimestamp(resource);
|
||||
if (spec == null) {
|
||||
return new ResourceVo(name, null, null, null, null, false, false, null,
|
||||
creationTimestamp, stats);
|
||||
}
|
||||
return new ResourceVo(name, spec.getSlug(), spec.getDisplayName(),
|
||||
spec.getDescription(), spec.getAttachmentName(),
|
||||
spec.isRequireEmailVerify(), spec.isEnabled(),
|
||||
"/download/" + spec.getSlug(), creationTimestamp, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
/**
|
||||
* Renders the self-contained HTML pages for the public download flow (no external CSS/JS
|
||||
* dependencies). All dynamic values are HTML-escaped before being embedded.
|
||||
*/
|
||||
public final class DownloadPageRenderer {
|
||||
|
||||
private DownloadPageRenderer() {
|
||||
}
|
||||
|
||||
public static String renderDownloadPage(String slug, String displayName,
|
||||
String description, boolean requireEmailVerify) {
|
||||
return DOWNLOAD_PAGE
|
||||
.replace("__TITLE__", escapeHtml(displayName))
|
||||
.replace("__SLUG__", escapeHtml(slug))
|
||||
.replace("__DISPLAY_NAME__", escapeHtml(displayName))
|
||||
.replace("__DESCRIPTION__", description == null ? "" : escapeHtml(description))
|
||||
.replace("__REQUIRE_VERIFY__", String.valueOf(requireEmailVerify));
|
||||
}
|
||||
|
||||
public static String renderNotFoundPage(String slug) {
|
||||
return renderErrorPage(404, "下载资源不存在或已停用");
|
||||
}
|
||||
|
||||
public static String renderErrorPage(int status, String message) {
|
||||
return ERROR_PAGE
|
||||
.replace("__STATUS__", String.valueOf(status))
|
||||
.replace("__MESSAGE__", escapeHtml(message));
|
||||
}
|
||||
|
||||
static String escapeHtml(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static final String SHARED_STYLE = """
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px 16px; color: #1f2937;
|
||||
background: linear-gradient(160deg, #eef2ff 0%, #f5f7fb 45%, #eef7f4 100%);
|
||||
}
|
||||
#app { width: 100%; max-width: 440px; }
|
||||
.card {
|
||||
background: #fff; border-radius: 20px; width: 100%; max-width: 440px;
|
||||
min-height: 520px; padding: 40px 36px 32px; text-align: center;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
box-shadow: 0 1px 2px rgba(16,24,40,.04),
|
||||
0 12px 32px -8px rgba(16,24,40,.12);
|
||||
border: 1px solid rgba(226,232,240,.8);
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String ERROR_PAGE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>__STATUS__ - 无法下载</title>
|
||||
<style>
|
||||
__SHARED_STYLE__
|
||||
.status-badge {
|
||||
width: 64px; height: 64px; margin: 0 auto; border-radius: 50%;
|
||||
background: #fef2f2; color: #dc2626;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; font-weight: 700;
|
||||
}
|
||||
.msg { margin-top: 18px; color: #4b5563; font-size: 15px; line-height: 1.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status-badge">__STATUS__</div>
|
||||
<p class="msg">__MESSAGE__</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
||||
|
||||
private static final String DOWNLOAD_PAGE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>__TITLE__ - 资源下载</title>
|
||||
<style>
|
||||
__SHARED_STYLE__
|
||||
.icon-wrap {
|
||||
width: 72px; height: 72px; margin: 0 auto 4px; border-radius: 20px;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.icon-wrap svg { width: 36px; height: 36px; }
|
||||
h1 {
|
||||
margin-top: 18px; font-size: 21px; font-weight: 600; color: #111827;
|
||||
line-height: 1.4; word-break: break-word;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 10px; color: #6b7280; font-size: 14px; line-height: 1.8;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.desc:empty { display: none; }
|
||||
.section { margin-top: 26px; }
|
||||
.section .fields { display: flex; flex-direction: column; gap: 10px; }
|
||||
input {
|
||||
width: 100%; padding: 11px 14px; border: 1px solid #d1d5db;
|
||||
border-radius: 10px; font-size: 14px; outline: none; background: #fff;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,.12); }
|
||||
.code-row { display: flex; gap: 10px; }
|
||||
.code-row input { flex: 1; min-width: 0; }
|
||||
.code-row button {
|
||||
flex-shrink: 0; padding: 0 16px; border: 1px solid #4f46e5;
|
||||
background: #fff; color: #4f46e5; border-radius: 10px; font-size: 13px;
|
||||
font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.code-row button:hover:not(:disabled) { background: #eef2ff; }
|
||||
.code-row button:disabled { border-color: #d1d5db; color: #9ca3af; cursor: not-allowed; }
|
||||
#download-btn {
|
||||
width: 100%; margin-top: 26px; padding: 13px 0; border: none;
|
||||
border-radius: 12px; color: #fff; font-size: 15px; font-weight: 600;
|
||||
cursor: pointer; letter-spacing: .05em;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
||||
box-shadow: 0 4px 14px -2px rgba(79,70,229,.45);
|
||||
transition: transform .12s, box-shadow .15s, opacity .15s;
|
||||
}
|
||||
#download-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px -2px rgba(79,70,229,.5);
|
||||
}
|
||||
#download-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
#download-btn:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }
|
||||
.tip {
|
||||
margin-top: 14px; font-size: 13px; color: #059669; display: flex;
|
||||
align-items: center; justify-content: center; gap: 5px;
|
||||
}
|
||||
.msg { margin-top: 16px; font-size: 13px; line-height: 1.6; min-height: 20px; }
|
||||
.msg.error { color: #dc2626; }
|
||||
.msg.info { color: #059669; }
|
||||
.footer {
|
||||
margin-top: 22px; font-size: 12px; color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" data-slug="__SLUG__" data-require-verify="__REQUIRE_VERIFY__">
|
||||
<div class="card">
|
||||
<div class="icon-wrap">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#4f46e5" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>__DISPLAY_NAME__</h1>
|
||||
<p class="desc">__DESCRIPTION__</p>
|
||||
<div id="verify-section" class="section" hidden>
|
||||
<div class="fields">
|
||||
<input id="email" type="email" placeholder="请输入邮箱地址" autocomplete="email">
|
||||
<div class="code-row">
|
||||
<input id="code" type="text" placeholder="验证码" maxlength="6"
|
||||
autocomplete="off" inputmode="numeric">
|
||||
<button id="send-btn" type="button">发送验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="verified-tip" class="tip" hidden>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
该邮箱已完成验证,可直接下载。
|
||||
</p>
|
||||
</div>
|
||||
<button id="download-btn" type="button">立即下载</button>
|
||||
<p id="msg" class="msg"></p>
|
||||
<p class="footer">安全下载 · 由 Sharelink 提供</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var app = document.getElementById('app');
|
||||
var slug = app.dataset.slug;
|
||||
var requireVerify = app.dataset.requireVerify === 'true';
|
||||
var API = '/apis/api.sharelink.halo.run/v1alpha1';
|
||||
var msg = document.getElementById('msg');
|
||||
var downloadBtn = document.getElementById('download-btn');
|
||||
|
||||
function showMsg(text, isError) {
|
||||
msg.textContent = text || '';
|
||||
msg.className = 'msg ' + (isError ? 'error' : 'info');
|
||||
}
|
||||
|
||||
function postJson(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (resp) {
|
||||
return resp.json().catch(function () { return null; }).then(function (data) {
|
||||
if (!resp.ok) {
|
||||
var detail = data && (data.detail || data.title)
|
||||
? (data.detail || data.title) : ('请求失败 (' + resp.status + ')');
|
||||
throw new Error(detail);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var emailInput = document.getElementById('email');
|
||||
var codeInput = document.getElementById('code');
|
||||
var sendBtn = document.getElementById('send-btn');
|
||||
var verifiedTip = document.getElementById('verified-tip');
|
||||
var emailVerified = false;
|
||||
|
||||
if (requireVerify) {
|
||||
document.getElementById('verify-section').hidden = false;
|
||||
|
||||
var checkTimer = null;
|
||||
emailInput.addEventListener('input', function () {
|
||||
emailVerified = false;
|
||||
verifiedTip.hidden = true;
|
||||
clearTimeout(checkTimer);
|
||||
var email = emailInput.value.trim();
|
||||
if (!email || email.indexOf('@') < 0) { return; }
|
||||
checkTimer = setTimeout(function () {
|
||||
postJson(API + '/email-verify/-/check', { email: email })
|
||||
.then(function (res) {
|
||||
if (res.verified) {
|
||||
emailVerified = true;
|
||||
verifiedTip.hidden = false;
|
||||
}
|
||||
})
|
||||
.catch(function () { /* 忽略,回退到验证码流程 */ });
|
||||
}, 500);
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', function () {
|
||||
var email = emailInput.value.trim();
|
||||
if (!email) { showMsg('请先输入邮箱地址', true); return; }
|
||||
sendBtn.disabled = true;
|
||||
postJson(API + '/email-verify/-/send', { email: email })
|
||||
.then(function (res) {
|
||||
showMsg('验证码已发送,请查收邮件(' + res.expireMinutes + ' 分钟内有效)', false);
|
||||
startCountdown(res.resendAfterSeconds || 60);
|
||||
})
|
||||
.catch(function (e) {
|
||||
showMsg(e.message, true);
|
||||
sendBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
function startCountdown(seconds) {
|
||||
var remaining = seconds;
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = remaining + ' 秒后重发';
|
||||
var timer = setInterval(function () {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timer);
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '发送验证码';
|
||||
} else {
|
||||
sendBtn.textContent = remaining + ' 秒后重发';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
downloadBtn.addEventListener('click', function () {
|
||||
showMsg('', false);
|
||||
var body = { slug: slug };
|
||||
if (requireVerify) {
|
||||
var email = emailInput.value.trim();
|
||||
if (!email) { showMsg('请输入邮箱地址', true); return; }
|
||||
body.email = email;
|
||||
if (!emailVerified) {
|
||||
var code = codeInput.value.trim();
|
||||
if (!code) {
|
||||
showMsg('请输入邮箱验证码;若该邮箱之前已验证过,可直接下载', true);
|
||||
return;
|
||||
}
|
||||
body.code = code;
|
||||
}
|
||||
}
|
||||
downloadBtn.disabled = true;
|
||||
postJson(API + '/downloads/-/token', body)
|
||||
.then(function (res) {
|
||||
showMsg('开始下载…', false);
|
||||
window.location.href = res.fileUrl;
|
||||
setTimeout(function () { downloadBtn.disabled = false; }, 3000);
|
||||
})
|
||||
.catch(function (e) {
|
||||
showMsg(e.message, true);
|
||||
downloadBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
|
||||
/**
|
||||
* Shared lookup helpers for {@link DownloadResource}.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DownloadResourceService {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
/**
|
||||
* Finds a resource by its (unique) slug.
|
||||
*/
|
||||
public Mono<DownloadResource> findBySlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadResource.class, options, Sort.unsorted())
|
||||
.next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
import run.halo.sharelink.emailcode.EmailCodeManager;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
import run.halo.sharelink.util.EmailUtils;
|
||||
import run.halo.sharelink.util.IpUtils;
|
||||
import run.halo.sharelink.verify.VerifiedEmailService;
|
||||
|
||||
/**
|
||||
* Public endpoint exchanging a (possibly verified) identity for a one-time download
|
||||
* token. Mounted under {@code /apis/api.sharelink.halo.run/v1alpha1} and opened to
|
||||
* anonymous visitors via the aggregate-to-anonymous role template.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DownloadTokenEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
private final VerifiedEmailService verifiedEmailService;
|
||||
private final EmailCodeManager emailCodeManager;
|
||||
private final DownloadTokenManager tokenManager;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return RouterFunctions.route()
|
||||
.POST("downloads/-/token", this::issueToken)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> issueToken(ServerRequest request) {
|
||||
return request.bodyToMono(TokenRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> {
|
||||
var slug = StringUtils.trimToNull(body.slug());
|
||||
if (slug == null) {
|
||||
return Mono.error(new ServerWebInputException("slug 不能为空"));
|
||||
}
|
||||
return findBySlug(slug)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "下载资源不存在")))
|
||||
.flatMap(resource -> doIssueToken(request, resource, body));
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> doIssueToken(ServerRequest request,
|
||||
DownloadResource resource,
|
||||
TokenRequest body) {
|
||||
var spec = resource.getSpec();
|
||||
if (!spec.isEnabled()) {
|
||||
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "该资源已停用"));
|
||||
}
|
||||
var clientIp = IpUtils.clientIp(request.exchange().getRequest());
|
||||
return settingConfigGetter.getBasicConfig()
|
||||
.flatMap(basic -> {
|
||||
if (!spec.isRequireEmailVerify()) {
|
||||
return respondWithToken(spec, null, clientIp, basic);
|
||||
}
|
||||
var email = EmailUtils.normalizeEmail(body.email());
|
||||
if (email == null || !EmailUtils.isValidEmail(email)) {
|
||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||
}
|
||||
return verifiedEmailService.isVerified(email)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("查询邮箱验证状态失败,按未验证处理: {}", email, e);
|
||||
return Mono.just(false);
|
||||
})
|
||||
.flatMap(verified -> {
|
||||
if (verified) {
|
||||
return respondWithToken(spec, email, clientIp, basic);
|
||||
}
|
||||
var code = StringUtils.trimToNull(body.code());
|
||||
if (code == null) {
|
||||
return Mono.error(new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "请先完成邮箱验证"));
|
||||
}
|
||||
return settingConfigGetter.getEmailVerifyConfig()
|
||||
.flatMap(config -> emailCodeManager.verify(email, code, config)
|
||||
.flatMap(valid -> {
|
||||
if (!valid) {
|
||||
return Mono.error(new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "验证码错误或已过期,请重新获取"));
|
||||
}
|
||||
return verifiedEmailService.recordVerified(email, clientIp)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("登记已验证邮箱失败: {}", email, e);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then(respondWithToken(spec, email, clientIp, basic));
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> respondWithToken(DownloadResource.Spec spec, String email,
|
||||
String clientIp,
|
||||
SharelinkSettingConfigGetter.BasicConfig basic) {
|
||||
var token = tokenManager.issue(spec.getSlug(), email, clientIp);
|
||||
var fileUrl = "/download/" + spec.getSlug() + "/file?token=" + token;
|
||||
return ServerResponse.ok().bodyValue(new TokenResponse(token, fileUrl));
|
||||
}
|
||||
|
||||
private Mono<DownloadResource> findBySlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadResource.class, options, Sort.unsorted()).next();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion("api.sharelink.halo.run/v1alpha1");
|
||||
}
|
||||
|
||||
public record TokenRequest(String slug, String email, String code) {
|
||||
}
|
||||
|
||||
public record TokenResponse(String token, String fileUrl) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Issues and consumes one-time download tokens. A token is bound to a resource slug (and
|
||||
* the verified email / client ip for auditing) and becomes invalid as soon as it is
|
||||
* consumed or its TTL (settings {@code basic.tokenTtlSeconds}) elapses.
|
||||
*/
|
||||
@Component
|
||||
public class DownloadTokenManager {
|
||||
|
||||
/**
|
||||
* Fallback eviction for never-consumed tokens; the effective expiration is checked
|
||||
* against the configured TTL on consumption.
|
||||
*/
|
||||
private static final Duration CACHE_TTL = Duration.ofMinutes(30);
|
||||
|
||||
private final Cache<String, TokenPayload> cache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(CACHE_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
public String issue(String slug, String email, String ip) {
|
||||
var token = UUID.randomUUID().toString();
|
||||
cache.put(token, new TokenPayload(slug, email, ip, Instant.now()));
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes the token: it is removed from the cache no matter the outcome, so a token
|
||||
* can never be used twice.
|
||||
*
|
||||
* @return the payload when the token exists and has not expired, otherwise
|
||||
* {@code null}
|
||||
*/
|
||||
public TokenPayload consume(String token, long ttlSeconds) {
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return null;
|
||||
}
|
||||
var payload = cache.getIfPresent(token);
|
||||
if (payload == null) {
|
||||
return null;
|
||||
}
|
||||
cache.invalidate(token);
|
||||
if (Instant.now().isAfter(payload.issuedAt().plusSeconds(ttlSeconds))) {
|
||||
return null;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
public record TokenPayload(String slug, String email, String ip, Instant issuedAt) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
import run.halo.sharelink.model.DownloadRecord;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
import run.halo.sharelink.util.IpUtils;
|
||||
|
||||
/**
|
||||
* Public download routes, registered as {@link RouterFunction} beans which Halo collects
|
||||
* from the plugin context:
|
||||
* <ul>
|
||||
* <li>{@code GET /download/{slug}} — the self-contained HTML download page</li>
|
||||
* <li>{@code GET /download/{slug}/file?token=...} — consumes a one-time token, records
|
||||
* the download, then streams the file</li>
|
||||
* </ul>
|
||||
* All responses are {@code Cache-Control: no-store}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DownloadWebRouter {
|
||||
|
||||
private static final MediaType HTML_UTF8 =
|
||||
new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8);
|
||||
|
||||
/**
|
||||
* Fallback eviction for dedupe entries; the effective window is checked against the
|
||||
* configured value on each hit.
|
||||
*/
|
||||
private static final Duration DEDUPE_CACHE_TTL = Duration.ofDays(1);
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
private final DownloadTokenManager tokenManager;
|
||||
private final FileStreamer fileStreamer;
|
||||
|
||||
private final Cache<String, Instant> dedupeCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(DEDUPE_CACHE_TTL)
|
||||
.maximumSize(50_000)
|
||||
.build();
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> downloadPageRoute() {
|
||||
return RouterFunctions.route()
|
||||
.GET("/download/{slug}", this::renderPage)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
RouterFunction<ServerResponse> downloadFileRoute() {
|
||||
return RouterFunctions.route()
|
||||
.GET("/download/{slug}/file", this::streamFile)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> renderPage(ServerRequest request) {
|
||||
var slug = request.pathVariable("slug");
|
||||
return findBySlug(slug)
|
||||
.filter(resource -> resource.getSpec().isEnabled())
|
||||
.flatMap(resource -> {
|
||||
var spec = resource.getSpec();
|
||||
var displayName = StringUtils.firstNonBlank(spec.getDisplayName(),
|
||||
spec.getSlug());
|
||||
var html = DownloadPageRenderer.renderDownloadPage(spec.getSlug(),
|
||||
displayName, spec.getDescription(), spec.isRequireEmailVerify());
|
||||
return ServerResponse.ok()
|
||||
.contentType(HTML_UTF8)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.bodyValue(html);
|
||||
})
|
||||
.switchIfEmpty(notFoundPage(slug));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> streamFile(ServerRequest request) {
|
||||
var slug = request.pathVariable("slug");
|
||||
var token = request.queryParam("token").orElse(null);
|
||||
return settingConfigGetter.getBasicConfig()
|
||||
.flatMap(basic -> {
|
||||
var payload = tokenManager.consume(token, basic.getTokenTtlSeconds());
|
||||
if (payload == null || !slug.equals(payload.slug())) {
|
||||
// Token missing, reused or expired: send the visitor back to the
|
||||
// download page to obtain a fresh one.
|
||||
return redirectToPage(slug);
|
||||
}
|
||||
return findBySlug(slug)
|
||||
.filter(resource -> resource.getSpec().isEnabled())
|
||||
.flatMap(resource -> fileStreamer.resolveAvailable(resource)
|
||||
.flatMap(attachment -> recordDownload(request, resource, payload,
|
||||
basic)
|
||||
.then(fileStreamer.stream(request, resource.getSpec(),
|
||||
attachment)))
|
||||
.switchIfEmpty(FileStreamer.errorPage(HttpStatus.NOT_FOUND,
|
||||
"附件暂不可用")))
|
||||
.switchIfEmpty(notFoundPage(slug));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the download (DownloadRecord + redundant counter). Best effort: failures
|
||||
* are logged and never block the download itself. Resources without email
|
||||
* verification dedupe repeated downloads from the same IP inside the configured
|
||||
* window (no record, no count, but the download still proceeds).
|
||||
*/
|
||||
private Mono<Void> recordDownload(ServerRequest request, DownloadResource resource,
|
||||
DownloadTokenManager.TokenPayload payload,
|
||||
SharelinkSettingConfigGetter.BasicConfig basic) {
|
||||
var spec = resource.getSpec();
|
||||
if (!spec.isRequireEmailVerify()
|
||||
&& isDuplicate(spec.getSlug(), payload.ip(), basic.getDedupeWindowMinutes())) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var record = new DownloadRecord();
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(UUID.randomUUID().toString());
|
||||
record.setMetadata(metadata);
|
||||
var recordSpec = new DownloadRecord.Spec();
|
||||
recordSpec.setResourceSlug(spec.getSlug());
|
||||
recordSpec.setEmail(payload.email());
|
||||
recordSpec.setIp(payload.ip());
|
||||
recordSpec.setUserAgent(
|
||||
StringUtils.truncate(request.headers().firstHeader("User-Agent"), 500));
|
||||
recordSpec.setDownloadedAt(Instant.now());
|
||||
record.setSpec(recordSpec);
|
||||
return client.create(record)
|
||||
.then(incrementDownloadCount(resource.getMetadata().getName()))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("记录下载失败: slug={}", spec.getSlug(), e);
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code true} when this slug+ip pair was already seen inside the dedupe
|
||||
* window
|
||||
*/
|
||||
private boolean isDuplicate(String slug, String ip, int windowMinutes) {
|
||||
if (windowMinutes <= 0) {
|
||||
return false;
|
||||
}
|
||||
var key = slug + "|" + (ip == null ? "-" : ip);
|
||||
var now = Instant.now();
|
||||
var last = dedupeCache.getIfPresent(key);
|
||||
if (last != null && now.isBefore(last.plusSeconds(windowMinutes * 60L))) {
|
||||
return true;
|
||||
}
|
||||
dedupeCache.put(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private Mono<Void> incrementDownloadCount(String resourceName) {
|
||||
return Mono.defer(() -> client.fetch(DownloadResource.class, resourceName)
|
||||
.flatMap(resource -> {
|
||||
var status = resource.getStatus();
|
||||
if (status == null) {
|
||||
status = new DownloadResource.Status();
|
||||
resource.setStatus(status);
|
||||
}
|
||||
status.setDownloadCount(status.getDownloadCount() + 1);
|
||||
return client.update(resource);
|
||||
}))
|
||||
// Optimistic-lock retry for concurrent downloads of the same resource.
|
||||
.retryWhen(Retry.max(3)
|
||||
.filter(OptimisticLockingFailureException.class::isInstance))
|
||||
.onErrorResume(e -> {
|
||||
log.warn("更新下载计数失败: {}", resourceName, e);
|
||||
return Mono.empty();
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<DownloadResource> findBySlug(String slug) {
|
||||
var options = ListOptions.builder()
|
||||
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||
.build();
|
||||
return client.listAll(DownloadResource.class, options, Sort.unsorted()).next();
|
||||
}
|
||||
|
||||
private static Mono<ServerResponse> redirectToPage(String slug) {
|
||||
return ServerResponse.status(HttpStatus.FOUND)
|
||||
.location(URI.create("/download/" + slug))
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Mono<ServerResponse> notFoundPage(String slug) {
|
||||
return ServerResponse.status(HttpStatus.NOT_FOUND)
|
||||
.contentType(HTML_UTF8)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.bodyValue(DownloadPageRenderer.renderNotFoundPage(slug));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.attachment.Constant;
|
||||
import run.halo.app.extension.MetadataUtil;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.ExternalUrlSupplier;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
|
||||
/**
|
||||
* Streams the attachment bytes of a download resource to the visitor.
|
||||
*
|
||||
* <p>For the local storage policy the file is read directly from disk
|
||||
* ({work-dir}/attachments/{local-relative-path}); for other policies the file is fetched
|
||||
* through a loopback HTTP request to its permalink (carrying the internal secret header
|
||||
* so {@code UploadProtectFilter} lets it pass). The body is forwarded as a
|
||||
* {@link DataBuffer} stream without buffering the whole file in memory.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FileStreamer {
|
||||
|
||||
private static final MediaType HTML_UTF8 =
|
||||
new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8);
|
||||
|
||||
private static final int BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final ExternalUrlSupplier externalUrlSupplier;
|
||||
private final InternalRequestSecret internalRequestSecret;
|
||||
private final Environment environment;
|
||||
|
||||
private final WebClient webClient = WebClient.builder().build();
|
||||
|
||||
/**
|
||||
* Resolves the attachment for the resource and emits it only when its bytes are
|
||||
* actually available (local file exists, or a permalink is present for the loopback
|
||||
* fallback). Emits empty otherwise, so callers can avoid counting failed downloads.
|
||||
*/
|
||||
public Mono<Attachment> resolveAvailable(DownloadResource resource) {
|
||||
return client.fetch(Attachment.class, resource.getSpec().getAttachmentName())
|
||||
.filter(attachment -> {
|
||||
if (resolveLocalFile(attachment) != null) {
|
||||
return true;
|
||||
}
|
||||
var permalink = attachment.getStatus() == null
|
||||
? null : attachment.getStatus().getPermalink();
|
||||
return StringUtils.isNotBlank(permalink);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams an attachment previously resolved via {@link #resolveAvailable}.
|
||||
*/
|
||||
public Mono<ServerResponse> stream(ServerRequest request, DownloadResource.Spec spec,
|
||||
Attachment attachment) {
|
||||
var localFile = resolveLocalFile(attachment);
|
||||
if (localFile != null) {
|
||||
return streamLocalFile(localFile, spec, attachment);
|
||||
}
|
||||
return streamViaLoopback(request, spec, attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated kept for compatibility; prefer {@link #resolveAvailable} +
|
||||
* {@link #stream(ServerRequest, DownloadResource.Spec, Attachment)}.
|
||||
*/
|
||||
@Deprecated
|
||||
public Mono<ServerResponse> stream(ServerRequest request, DownloadResource resource) {
|
||||
var spec = resource.getSpec();
|
||||
return client.fetch(Attachment.class, spec.getAttachmentName())
|
||||
.flatMap(attachment -> {
|
||||
var localFile = resolveLocalFile(attachment);
|
||||
if (localFile != null) {
|
||||
return streamLocalFile(localFile, spec, attachment);
|
||||
}
|
||||
return streamViaLoopback(request, spec, attachment);
|
||||
})
|
||||
.switchIfEmpty(errorPage(HttpStatus.NOT_FOUND, "附件不存在"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the on-disk path for attachments stored by the local storage policy,
|
||||
* or {@code null} when the attachment is not a local file.
|
||||
*/
|
||||
private Path resolveLocalFile(Attachment attachment) {
|
||||
var annotations = MetadataUtil.nullSafeAnnotations(attachment);
|
||||
var relPath = annotations.get(Constant.LOCAL_REL_PATH_ANNO_KEY);
|
||||
if (StringUtils.isBlank(relPath)) {
|
||||
return null;
|
||||
}
|
||||
var workDir = environment.getProperty("halo.work-dir",
|
||||
System.getProperty("user.home") + "/.halo2");
|
||||
var attachmentsRoot = Paths.get(workDir).resolve("attachments").normalize();
|
||||
var file = attachmentsRoot.resolve(relPath).normalize();
|
||||
if (!file.startsWith(attachmentsRoot)) {
|
||||
log.warn("附件路径越界,已拒绝: {}", relPath);
|
||||
return null;
|
||||
}
|
||||
if (!Files.isRegularFile(file)) {
|
||||
log.warn("附件文件不存在: {}", file);
|
||||
return null;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> streamLocalFile(Path file, DownloadResource.Spec spec,
|
||||
Attachment attachment) {
|
||||
return Mono.fromCallable(() -> Files.size(file))
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.flatMap(size -> {
|
||||
var filename = resolveFilename(spec, attachment);
|
||||
var builder = ServerResponse.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(filename))
|
||||
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(size));
|
||||
var mediaType = mediaTypeOf(attachment);
|
||||
if (mediaType != null) {
|
||||
builder.contentType(mediaType);
|
||||
}
|
||||
var body = DataBufferUtils.read(file,
|
||||
DefaultDataBufferFactory.sharedInstance, BUFFER_SIZE);
|
||||
return builder.body(body, DataBuffer.class);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> streamViaLoopback(ServerRequest request,
|
||||
DownloadResource.Spec spec, Attachment attachment) {
|
||||
var permalink = attachment.getStatus() == null
|
||||
? null : attachment.getStatus().getPermalink();
|
||||
if (StringUtils.isBlank(permalink)) {
|
||||
return errorPage(HttpStatus.NOT_FOUND, "附件暂不可用");
|
||||
}
|
||||
final URI fileUri;
|
||||
try {
|
||||
fileUri = externalUrlSupplier.getURL(request.exchange().getRequest())
|
||||
.toURI().resolve(permalink);
|
||||
} catch (URISyntaxException e) {
|
||||
log.warn("拼接附件下载地址失败: {}", permalink, e);
|
||||
return errorPage(HttpStatus.INTERNAL_SERVER_ERROR, "附件地址解析失败");
|
||||
}
|
||||
return webClient.get()
|
||||
.uri(fileUri)
|
||||
.header(InternalRequestSecret.HEADER, internalRequestSecret.value())
|
||||
.exchangeToMono(response -> {
|
||||
if (!response.statusCode().is2xxSuccessful()) {
|
||||
log.warn("回环请求附件失败: {} -> {}", fileUri, response.statusCode());
|
||||
return response.releaseBody()
|
||||
.then(errorPage(HttpStatus.NOT_FOUND, "附件暂不可用"));
|
||||
}
|
||||
var filename = resolveFilename(spec, attachment);
|
||||
var builder = ServerResponse.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
contentDisposition(filename));
|
||||
response.headers().contentType().ifPresent(builder::contentType);
|
||||
var contentLength = response.headers().contentLength();
|
||||
if (contentLength.isPresent()) {
|
||||
builder.header(HttpHeaders.CONTENT_LENGTH,
|
||||
String.valueOf(contentLength.getAsLong()));
|
||||
}
|
||||
return builder.body(response.bodyToFlux(DataBuffer.class),
|
||||
DataBuffer.class);
|
||||
});
|
||||
}
|
||||
|
||||
private static MediaType mediaTypeOf(Attachment attachment) {
|
||||
var mediaType = attachment.getSpec() == null
|
||||
? null : attachment.getSpec().getMediaType();
|
||||
if (StringUtils.isBlank(mediaType)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MediaType.parseMediaType(mediaType);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveFilename(DownloadResource.Spec spec, Attachment attachment) {
|
||||
var attachmentName = attachment.getSpec() == null
|
||||
? null : attachment.getSpec().getDisplayName();
|
||||
var filename = StringUtils.firstNonBlank(spec.getDisplayName(), attachmentName,
|
||||
"download");
|
||||
// Append the original extension when the display name carries none.
|
||||
if (StringUtils.isNotBlank(attachmentName)
|
||||
&& attachmentName.contains(".")
|
||||
&& !filename.contains(".")) {
|
||||
filename += attachmentName.substring(attachmentName.lastIndexOf('.'));
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
private static String contentDisposition(String filename) {
|
||||
var encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8)
|
||||
.replace("+", "%20");
|
||||
return "attachment; filename*=UTF-8''" + encoded;
|
||||
}
|
||||
|
||||
public static Mono<ServerResponse> errorPage(HttpStatus status, String message) {
|
||||
return ServerResponse.status(status)
|
||||
.contentType(HTML_UTF8)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.bodyValue(DownloadPageRenderer.renderErrorPage(status.value(), message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package run.halo.sharelink.download;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.UUID;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* A random secret generated at plugin startup. {@code FileStreamer} attaches it as the
|
||||
* {@value #HEADER} header on its loopback requests so that
|
||||
* {@code UploadProtectFilter} lets them through, while external requests to the same
|
||||
* {@code /upload/**} permalinks are rejected.
|
||||
*/
|
||||
@Component
|
||||
public class InternalRequestSecret {
|
||||
|
||||
public static final String HEADER = "X-Sharelink-Internal";
|
||||
|
||||
private final String value = UUID.randomUUID().toString();
|
||||
|
||||
public String value() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public boolean matches(String headerValue) {
|
||||
return headerValue != null
|
||||
&& MessageDigest.isEqual(value.getBytes(StandardCharsets.UTF_8),
|
||||
headerValue.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package run.halo.sharelink.emailcode;
|
||||
|
||||
import java.net.URI;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
|
||||
public interface EmailCodeManager {
|
||||
|
||||
/**
|
||||
* Sends a verification code to the given email. Fails with
|
||||
* {@link SendRateLimitedException} when any rate limit is exceeded.
|
||||
*
|
||||
* @param email normalized email (lowercased and trimmed)
|
||||
* @param clientIp client ip for auxiliary rate limiting, may be null
|
||||
* @param config email verify config
|
||||
*/
|
||||
Mono<Void> sendCode(String email, String clientIp,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config);
|
||||
|
||||
/**
|
||||
* Verifies the code for the given email. Each invocation accumulates attempts and the
|
||||
* code is invalidated once the max attempts is exceeded or the verification succeeds.
|
||||
*/
|
||||
Mono<Boolean> verify(String email, String code,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config);
|
||||
|
||||
Mono<Void> invalidate(String email);
|
||||
|
||||
class SendRateLimitedException extends ResponseStatusException {
|
||||
public static final String TYPE =
|
||||
"https://www.halo.run/probs/email-code-send-rate-limited";
|
||||
|
||||
public SendRateLimitedException(Type type) {
|
||||
super(HttpStatus.TOO_MANY_REQUESTS, type.detail);
|
||||
setType(URI.create(TYPE));
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
RESEND_TOO_FREQUENT("验证码发送过于频繁,请稍后再试"),
|
||||
DAILY_LIMIT_EXCEEDED("今日验证码发送次数已达上限,请明天再试"),
|
||||
IP_HOURLY_LIMIT_EXCEEDED("当前网络环境验证码发送过于频繁,请稍后再试");
|
||||
|
||||
private final String detail;
|
||||
|
||||
Type(String detail) {
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package run.halo.sharelink.emailcode;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
|
||||
/**
|
||||
* In-memory verification code store: one active code per email, one-time use, resend /
|
||||
* daily / ip-hourly rate limits, and a constant-time comparison.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EmailCodeManagerImpl implements EmailCodeManager {
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final int CODE_BOUND = 1_000_000;
|
||||
/**
|
||||
* The real expiration is checked against the config on verification, this TTL is only
|
||||
* a fallback to evict stale cache entries.
|
||||
*/
|
||||
private static final Duration CODE_CACHE_TTL = Duration.ofHours(1);
|
||||
private static final Duration RESEND_CACHE_TTL = Duration.ofMinutes(10);
|
||||
private static final Duration DAILY_LIMIT_TTL = Duration.ofDays(1);
|
||||
private static final Duration IP_HOURLY_LIMIT_TTL = Duration.ofHours(1);
|
||||
|
||||
private final Cache<String, EmailCodeEntry> codeCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(CODE_CACHE_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final Cache<String, Instant> resendCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(RESEND_CACHE_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final Cache<String, AtomicInteger> dailySendCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(DAILY_LIMIT_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final Cache<String, AtomicInteger> ipHourlySendCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(IP_HOURLY_LIMIT_TTL)
|
||||
.maximumSize(10_000)
|
||||
.build();
|
||||
|
||||
private final EmailCodeNotificationSender notificationSender;
|
||||
|
||||
@Override
|
||||
public Mono<Void> sendCode(String email, String clientIp,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config) {
|
||||
return Mono.defer(() -> {
|
||||
checkRateLimits(email, clientIp, config);
|
||||
var code = generateCode();
|
||||
return notificationSender.sendVerificationCode(email, email, code,
|
||||
config.getCodeExpireMinutes())
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
// Record the code and rate limits only after the notification is emitted,
|
||||
// so failures neither count against the quota nor overwrite the old code.
|
||||
.doOnSuccess(sent -> recordSent(email, clientIp, code));
|
||||
});
|
||||
}
|
||||
|
||||
private void checkRateLimits(String email, String clientIp,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config) {
|
||||
var lastSentAt = resendCache.getIfPresent(email);
|
||||
if (lastSentAt != null
|
||||
&& Instant.now().isBefore(lastSentAt.plusSeconds(config.getResendIntervalSeconds()))) {
|
||||
throw new SendRateLimitedException(SendRateLimitedException.Type.RESEND_TOO_FREQUENT);
|
||||
}
|
||||
var dailyCounter = dailySendCache.getIfPresent(email);
|
||||
if (dailyCounter != null && dailyCounter.get() >= config.getDailySendLimitPerEmail()) {
|
||||
throw new SendRateLimitedException(
|
||||
SendRateLimitedException.Type.DAILY_LIMIT_EXCEEDED);
|
||||
}
|
||||
if (StringUtils.isNotBlank(clientIp)) {
|
||||
var ipCounter = ipHourlySendCache.getIfPresent(clientIp);
|
||||
if (ipCounter != null && ipCounter.get() >= config.getIpHourlySendLimit()) {
|
||||
throw new SendRateLimitedException(
|
||||
SendRateLimitedException.Type.IP_HOURLY_LIMIT_EXCEEDED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void recordSent(String email, String clientIp, String code) {
|
||||
// Resending overwrites the previous code.
|
||||
codeCache.put(email, new EmailCodeEntry(code, Instant.now(), new AtomicInteger()));
|
||||
resendCache.put(email, Instant.now());
|
||||
incrementCounter(dailySendCache, email);
|
||||
if (StringUtils.isNotBlank(clientIp)) {
|
||||
incrementCounter(ipHourlySendCache, clientIp);
|
||||
}
|
||||
}
|
||||
|
||||
private static void incrementCounter(Cache<String, AtomicInteger> cache, String key) {
|
||||
var counter = cache.getIfPresent(key);
|
||||
if (counter == null) {
|
||||
counter = new AtomicInteger();
|
||||
cache.put(key, counter);
|
||||
}
|
||||
counter.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Boolean> verify(String email, String code,
|
||||
SharelinkSettingConfigGetter.EmailVerifyConfig config) {
|
||||
return Mono.fromSupplier(() -> {
|
||||
var entry = codeCache.getIfPresent(email);
|
||||
if (entry == null) {
|
||||
return false;
|
||||
}
|
||||
if (entry.attempts().incrementAndGet() > config.getMaxVerifyAttempts()) {
|
||||
codeCache.invalidate(email);
|
||||
return false;
|
||||
}
|
||||
if (Instant.now()
|
||||
.isAfter(entry.createdAt().plusSeconds(config.getCodeExpireMinutes() * 60L))) {
|
||||
codeCache.invalidate(email);
|
||||
return false;
|
||||
}
|
||||
var matched = MessageDigest.isEqual(entry.code().getBytes(),
|
||||
code.getBytes());
|
||||
if (matched) {
|
||||
// One-time use.
|
||||
codeCache.invalidate(email);
|
||||
}
|
||||
return matched;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> invalidate(String email) {
|
||||
codeCache.invalidate(email);
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
private static String generateCode() {
|
||||
return String.format(Locale.ROOT, "%06d", RANDOM.nextInt(CODE_BOUND));
|
||||
}
|
||||
|
||||
record EmailCodeEntry(String code, Instant createdAt, AtomicInteger attempts) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package run.halo.sharelink.emailcode;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.notification.Reason;
|
||||
import run.halo.app.core.extension.notification.Subscription;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.app.notification.NotificationCenter;
|
||||
import run.halo.app.notification.NotificationReasonEmitter;
|
||||
import run.halo.app.notification.UserIdentity;
|
||||
|
||||
/**
|
||||
* Sends email verification codes through the Halo notification chain. The email address
|
||||
* is resolved by the core subscriber email resolver from the anonymous user identity
|
||||
* ({@code anonymousUser#<email>}), so an email notifier (SMTP) must be configured in the
|
||||
* console first.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EmailCodeNotificationSender {
|
||||
public static final String REASON_TYPE = "sharelink-download-verification";
|
||||
|
||||
private final NotificationReasonEmitter reasonEmitter;
|
||||
private final NotificationCenter notificationCenter;
|
||||
|
||||
public Mono<Void> sendVerificationCode(String email, String displayName, String code,
|
||||
long expirationMinutes) {
|
||||
var identity = UserIdentity.anonymousWithEmail(email);
|
||||
var interestReason = createInterestReason(identity);
|
||||
var subscribe = notificationCenter.subscribe(
|
||||
createSubscriber(identity), interestReason);
|
||||
var emitReason = reasonEmitter.emit(REASON_TYPE, builder -> builder
|
||||
.attribute("code", code)
|
||||
.attribute("expirationAtMinutes", String.valueOf(expirationMinutes))
|
||||
.attribute("username", displayName)
|
||||
.author(identity)
|
||||
.subject(Reason.Subject.builder()
|
||||
.apiVersion(interestReason.getSubject().getApiVersion())
|
||||
.kind(User.KIND)
|
||||
.name(identity.name())
|
||||
.title("资源下载邮箱验证:" + email)
|
||||
.build()));
|
||||
return Mono.when(subscribe).then(emitReason);
|
||||
}
|
||||
|
||||
public Mono<Void> unsubscribe(String email) {
|
||||
var identity = UserIdentity.anonymousWithEmail(email);
|
||||
return notificationCenter.unsubscribe(createSubscriber(identity),
|
||||
createInterestReason(identity));
|
||||
}
|
||||
|
||||
private static Subscription.Subscriber createSubscriber(UserIdentity identity) {
|
||||
var subscriber = new Subscription.Subscriber();
|
||||
subscriber.setName(identity.name());
|
||||
return subscriber;
|
||||
}
|
||||
|
||||
private static Subscription.InterestReason createInterestReason(UserIdentity identity) {
|
||||
var interestReason = new Subscription.InterestReason();
|
||||
interestReason.setReasonType(REASON_TYPE);
|
||||
interestReason.setSubject(Subscription.ReasonSubject.builder()
|
||||
.apiVersion(new GroupVersion(User.GROUP, User.KIND).toString())
|
||||
.kind(User.KIND)
|
||||
.name(identity.name())
|
||||
.build());
|
||||
return interestReason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package run.halo.sharelink.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* A single download event. The metadata name is a random UUID; {@code spec.resourceSlug}
|
||||
* is indexed for per-resource queries.
|
||||
*/
|
||||
@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "DownloadRecord",
|
||||
plural = "downloadrecords", singular = "downloadrecord")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class DownloadRecord extends AbstractExtension {
|
||||
|
||||
private Spec spec;
|
||||
|
||||
@Data
|
||||
public static class Spec {
|
||||
|
||||
private String resourceSlug;
|
||||
|
||||
/**
|
||||
* Verified email of the downloader; blank for resources without email
|
||||
* verification.
|
||||
*/
|
||||
private String email;
|
||||
|
||||
private String ip;
|
||||
|
||||
private String userAgent;
|
||||
|
||||
private Instant downloadedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package run.halo.sharelink.model;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* A downloadable resource registered from an attachment. The {@code spec.slug} is the
|
||||
* public URL identifier ({@code /download/{slug}}) and carries a unique index.
|
||||
*/
|
||||
@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "DownloadResource",
|
||||
plural = "downloadresources", singular = "downloadresource")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class DownloadResource extends AbstractExtension {
|
||||
|
||||
private Spec spec;
|
||||
|
||||
private Status status;
|
||||
|
||||
@Data
|
||||
public static class Spec {
|
||||
|
||||
/**
|
||||
* URL identifier of the resource, unique across all resources. Matches
|
||||
* {@code [a-z0-9][a-z0-9-]{0,63}}.
|
||||
*/
|
||||
private String slug;
|
||||
|
||||
private String displayName;
|
||||
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* Metadata name of the associated {@code Attachment} extension.
|
||||
*/
|
||||
private String attachmentName;
|
||||
|
||||
private boolean requireEmailVerify;
|
||||
|
||||
private boolean enabled;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Status {
|
||||
|
||||
/**
|
||||
* Redundant download counter; the authoritative source is DownloadRecord.
|
||||
*/
|
||||
private long downloadCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package run.halo.sharelink.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
|
||||
/**
|
||||
* Registry entry for an email address that has passed download email verification. The
|
||||
* metadata name is the SHA-256 hex of the normalized email, which keeps it unique and
|
||||
* stable across verifications.
|
||||
*/
|
||||
@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "VerifiedDownloader",
|
||||
plural = "verifieddownloaders", singular = "verifieddownloader")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class VerifiedDownloader extends AbstractExtension {
|
||||
|
||||
private Spec spec;
|
||||
|
||||
@Data
|
||||
public static class Spec {
|
||||
|
||||
/**
|
||||
* The normalized (trimmed, lower-cased) email address.
|
||||
*/
|
||||
private String email;
|
||||
|
||||
private Instant firstVerifiedAt;
|
||||
|
||||
private Instant lastVerifiedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package run.halo.sharelink.protect;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Set;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.security.AdditionalWebFilter;
|
||||
import run.halo.sharelink.download.InternalRequestSecret;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
|
||||
/**
|
||||
* Blocks direct access to {@code /upload/**} permalinks of attachments that are
|
||||
* registered as (enabled) download resources, so visitors must go through the download
|
||||
* page. Loopback requests from {@code FileStreamer} carrying the internal secret header
|
||||
* are always allowed. The protected permalink set is cached for 60 seconds and can be
|
||||
* invalidated explicitly after console mutations.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class UploadProtectFilter implements AdditionalWebFilter {
|
||||
|
||||
private static final String UPLOAD_PREFIX = "/upload/";
|
||||
private static final String CACHE_KEY = "protected-permalinks";
|
||||
private static final Duration CACHE_TTL = Duration.ofSeconds(60);
|
||||
private static final int PERMALINK_LOAD_CONCURRENCY = 8;
|
||||
|
||||
private static final byte[] NOT_FOUND_BODY = ("{\"type\":\"about:blank\","
|
||||
+ "\"title\":\"Not Found\",\"status\":404,\"detail\":\"资源不存在\"}")
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final InternalRequestSecret internalRequestSecret;
|
||||
|
||||
private final Cache<String, Mono<Set<String>>> permalinkCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(CACHE_TTL)
|
||||
.maximumSize(1)
|
||||
.build();
|
||||
|
||||
/**
|
||||
* Drops the cached permalink set; called by the console endpoint after resource
|
||||
* create/update/delete.
|
||||
*/
|
||||
public void invalidate() {
|
||||
permalinkCache.invalidateAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Mono<Void> filter(@NonNull ServerWebExchange exchange,
|
||||
@NonNull WebFilterChain chain) {
|
||||
var request = exchange.getRequest();
|
||||
var method = request.getMethod();
|
||||
if (method != HttpMethod.GET && method != HttpMethod.HEAD) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
var rawPath = request.getURI().getRawPath();
|
||||
if (rawPath == null || !rawPath.startsWith(UPLOAD_PREFIX)) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
if (internalRequestSecret.matches(
|
||||
request.getHeaders().getFirst(InternalRequestSecret.HEADER))) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
var decodedPath = request.getURI().getPath();
|
||||
return protectedPermalinks()
|
||||
.flatMap(permalinks -> {
|
||||
if (permalinks.contains(rawPath) || permalinks.contains(decodedPath)) {
|
||||
return notFound(exchange);
|
||||
}
|
||||
return chain.filter(exchange);
|
||||
})
|
||||
// Fail open: a lookup failure must not break unrelated /upload/ requests.
|
||||
.onErrorResume(e -> {
|
||||
log.warn("查询受保护附件列表失败,放行本次请求: {}", rawPath, e);
|
||||
return chain.filter(exchange);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<Void> notFound(ServerWebExchange exchange) {
|
||||
var response = exchange.getResponse();
|
||||
response.setStatusCode(HttpStatus.NOT_FOUND);
|
||||
response.getHeaders().setContentType(MediaType.APPLICATION_PROBLEM_JSON);
|
||||
return response.writeWith(
|
||||
Mono.just(response.bufferFactory().wrap(NOT_FOUND_BODY)));
|
||||
}
|
||||
|
||||
private Mono<Set<String>> protectedPermalinks() {
|
||||
var cached = permalinkCache.getIfPresent(CACHE_KEY);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
var loading = client.listAll(DownloadResource.class, new ListOptions(),
|
||||
Sort.unsorted())
|
||||
.filter(resource -> resource.getSpec() != null
|
||||
&& resource.getSpec().isEnabled()
|
||||
&& StringUtils.isNotBlank(resource.getSpec().getAttachmentName()))
|
||||
.flatMap(resource -> client.fetch(Attachment.class,
|
||||
resource.getSpec().getAttachmentName())
|
||||
.flatMap(attachment -> Mono.justOrEmpty(
|
||||
attachment.getStatus() == null
|
||||
? null : attachment.getStatus().getPermalink()))
|
||||
.onErrorResume(e -> Mono.empty()),
|
||||
PERMALINK_LOAD_CONCURRENCY)
|
||||
.filter(permalink -> permalink.startsWith(UPLOAD_PREFIX))
|
||||
.collect(Collectors.toSet())
|
||||
.map(Collections::unmodifiableSet)
|
||||
.cache()
|
||||
// Never keep a failed load in the cache.
|
||||
.doOnError(e -> permalinkCache.invalidate(CACHE_KEY));
|
||||
permalinkCache.put(CACHE_KEY, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return SecurityWebFiltersOrder.AUTHORIZATION.getOrder() + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package run.halo.sharelink.reference;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.google.common.cache.Cache;
|
||||
import com.google.common.cache.CacheBuilder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.content.Post;
|
||||
import run.halo.app.core.extension.content.Snapshot;
|
||||
import run.halo.app.extension.ListOptions;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.sharelink.model.DownloadResource;
|
||||
|
||||
/**
|
||||
* Scans published posts for occurrences of {@code /download/{slug}} and produces a
|
||||
* resource-slug → referencing-posts map. The result is cached for 5 minutes; a failed
|
||||
* post never breaks the whole scan.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PostReferenceService {
|
||||
|
||||
private static final String CACHE_KEY = "references";
|
||||
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
|
||||
private static final int SCAN_CONCURRENCY = 8;
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
|
||||
private final Cache<String, Mono<Map<String, List<PostRef>>>> referenceCache =
|
||||
CacheBuilder.newBuilder()
|
||||
.expireAfterWrite(CACHE_TTL)
|
||||
.maximumSize(1)
|
||||
.build();
|
||||
|
||||
public Mono<Map<String, List<PostRef>>> references() {
|
||||
var cached = referenceCache.getIfPresent(CACHE_KEY);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
var loading = scan()
|
||||
.cache()
|
||||
// Never keep a failed scan in the cache.
|
||||
.doOnError(e -> referenceCache.invalidate(CACHE_KEY));
|
||||
referenceCache.put(CACHE_KEY, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a rescan, discarding the cached result.
|
||||
*/
|
||||
public Mono<Map<String, List<PostRef>>> refresh() {
|
||||
referenceCache.invalidateAll();
|
||||
return references();
|
||||
}
|
||||
|
||||
private Mono<Map<String, List<PostRef>>> scan() {
|
||||
return client.listAll(DownloadResource.class, new ListOptions(), Sort.unsorted())
|
||||
.map(resource -> resource.getSpec() == null ? null : resource.getSpec().getSlug())
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.collectList()
|
||||
.flatMap(slugs -> {
|
||||
if (slugs.isEmpty()) {
|
||||
return Mono.just(Map.<String, List<PostRef>>of());
|
||||
}
|
||||
return client.listAll(Post.class, new ListOptions(), Sort.unsorted())
|
||||
.filter(post -> !post.isDeleted()
|
||||
&& post.getSpec() != null
|
||||
&& StringUtils.isNotBlank(post.getSpec().getReleaseSnapshot()))
|
||||
.flatMap(post -> referencesOfPost(post, slugs)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("扫描文章引用失败,跳过: {}",
|
||||
post.getMetadata().getName(), e);
|
||||
return Flux.empty();
|
||||
}),
|
||||
SCAN_CONCURRENCY)
|
||||
.collectList()
|
||||
.map(PostReferenceService::groupBySlug);
|
||||
});
|
||||
}
|
||||
|
||||
private Flux<Map.Entry<String, PostRef>> referencesOfPost(Post post, List<String> slugs) {
|
||||
return client.fetch(Snapshot.class, post.getSpec().getReleaseSnapshot())
|
||||
.flatMapMany(snapshot -> {
|
||||
var snapshotSpec = snapshot.getSpec();
|
||||
var content = snapshotSpec == null ? null
|
||||
: StringUtils.firstNonBlank(snapshotSpec.getRawPatch(),
|
||||
snapshotSpec.getContentPatch());
|
||||
if (content == null || !content.contains("/download/")) {
|
||||
return Flux.empty();
|
||||
}
|
||||
var ref = PostRef.from(post);
|
||||
return Flux.fromIterable(slugs)
|
||||
.filter(slug -> content.contains("/download/" + slug))
|
||||
.map(slug -> Map.entry(slug, ref));
|
||||
});
|
||||
}
|
||||
|
||||
private static Map<String, List<PostRef>> groupBySlug(
|
||||
List<Map.Entry<String, PostRef>> entries) {
|
||||
Map<String, List<PostRef>> result = new LinkedHashMap<>();
|
||||
for (var entry : entries) {
|
||||
result.computeIfAbsent(entry.getKey(), key -> new ArrayList<>())
|
||||
.add(entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public record PostRef(String postName, String title, String permalink,
|
||||
String editorUrl) {
|
||||
|
||||
static PostRef from(Post post) {
|
||||
var name = post.getMetadata().getName();
|
||||
var title = post.getSpec() == null || StringUtils.isBlank(post.getSpec().getTitle())
|
||||
? name : post.getSpec().getTitle();
|
||||
var permalink = post.getStatus() == null ? null : post.getStatus().getPermalink();
|
||||
return new PostRef(name, title, permalink,
|
||||
"/console/posts/editor?name=" + name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package run.halo.sharelink.util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
/**
|
||||
* Email normalization and hashing helpers shared by the verification flow.
|
||||
*/
|
||||
public final class EmailUtils {
|
||||
|
||||
private static final Pattern EMAIL_PATTERN =
|
||||
Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$");
|
||||
|
||||
private EmailUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an email address (trimmed, lower-cased).
|
||||
*
|
||||
* @return the normalized email, or {@code null} when blank
|
||||
*/
|
||||
public static String normalizeEmail(String email) {
|
||||
if (email == null) {
|
||||
return null;
|
||||
}
|
||||
var normalized = email.trim().toLowerCase(Locale.ROOT);
|
||||
return StringUtils.isBlank(normalized) ? null : normalized;
|
||||
}
|
||||
|
||||
public static boolean isValidEmail(String email) {
|
||||
return email != null && EMAIL_PATTERN.matcher(email).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-256 hex of the given value; used as the deterministic metadata name of
|
||||
* {@code VerifiedDownloader} (and the comment plugin's {@code VerifiedCommenter}).
|
||||
*/
|
||||
public static String sha256Hex(String value) {
|
||||
try {
|
||||
var digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of()
|
||||
.formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 algorithm is unavailable", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package run.halo.sharelink.util;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
|
||||
/**
|
||||
* Resolves the client IP address of a request: the first {@code X-Forwarded-For} segment
|
||||
* wins, falling back to the remote address of the connection.
|
||||
*/
|
||||
public final class IpUtils {
|
||||
|
||||
private static final String X_FORWARDED_FOR = "X-Forwarded-For";
|
||||
|
||||
private IpUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the client IP address.
|
||||
*
|
||||
* @param request the current request
|
||||
* @return the client IP, or {@code null} when neither a forwarded header nor a remote
|
||||
* address is available
|
||||
*/
|
||||
public static String clientIp(ServerHttpRequest request) {
|
||||
var forwardedFor = request.getHeaders().getFirst(X_FORWARDED_FOR);
|
||||
if (StringUtils.isNotBlank(forwardedFor)) {
|
||||
return forwardedFor.split(",")[0].trim();
|
||||
}
|
||||
var remoteAddress = request.getRemoteAddress();
|
||||
if (remoteAddress == null) {
|
||||
return null;
|
||||
}
|
||||
var address = remoteAddress.getAddress();
|
||||
return address != null ? address.getHostAddress() : remoteAddress.getHostString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package run.halo.sharelink.verify;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||
import run.halo.app.extension.GroupVersion;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
import run.halo.sharelink.emailcode.EmailCodeManager;
|
||||
import run.halo.sharelink.util.EmailUtils;
|
||||
import run.halo.sharelink.util.IpUtils;
|
||||
|
||||
/**
|
||||
* Public endpoints for the download email verification flow, mounted under
|
||||
* {@code /apis/api.sharelink.halo.run/v1alpha1} and opened to anonymous visitors via the
|
||||
* aggregate-to-anonymous role template.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EmailVerifyEndpoint implements CustomEndpoint {
|
||||
|
||||
private final EmailCodeManager emailCodeManager;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
private final VerifiedEmailService verifiedEmailService;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
return RouterFunctions.route()
|
||||
.POST("email-verify/-/send", this::sendCode)
|
||||
.POST("email-verify/-/check", this::checkVerified)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> sendCode(ServerRequest request) {
|
||||
return settingConfigGetter.getEmailVerifyConfig()
|
||||
.flatMap(config -> request.bodyToMono(SendCodeRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> {
|
||||
var email = EmailUtils.normalizeEmail(body.email());
|
||||
if (email == null || !EmailUtils.isValidEmail(email)) {
|
||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||
}
|
||||
var clientIp = IpUtils.clientIp(request.exchange().getRequest());
|
||||
return emailCodeManager.sendCode(email, clientIp, config)
|
||||
.then(ServerResponse.ok().bodyValue(new SendCodeResponse(true,
|
||||
config.getCodeExpireMinutes(),
|
||||
config.getResendIntervalSeconds())));
|
||||
}));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> checkVerified(ServerRequest request) {
|
||||
return request.bodyToMono(CheckRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
.flatMap(body -> {
|
||||
var email = EmailUtils.normalizeEmail(body.email());
|
||||
if (email == null || !EmailUtils.isValidEmail(email)) {
|
||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||
}
|
||||
return verifiedEmailService.isVerified(email)
|
||||
// 登记查询失败按未验证处理,前端会回退到发送验证码流程。
|
||||
.onErrorResume(e -> {
|
||||
log.warn("查询邮箱验证状态失败,按未验证处理: {}", email, e);
|
||||
return Mono.just(false);
|
||||
})
|
||||
.flatMap(verified -> ServerResponse.ok()
|
||||
.bodyValue(new CheckResponse(verified)));
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public GroupVersion groupVersion() {
|
||||
return GroupVersion.parseAPIVersion("api.sharelink.halo.run/v1alpha1");
|
||||
}
|
||||
|
||||
public record SendCodeRequest(String email) {
|
||||
}
|
||||
|
||||
public record SendCodeResponse(boolean sent, long expireMinutes, long resendAfterSeconds) {
|
||||
}
|
||||
|
||||
public record CheckRequest(String email) {
|
||||
}
|
||||
|
||||
public record CheckResponse(boolean verified) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package run.halo.sharelink.verify;
|
||||
|
||||
import java.time.Instant;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.sharelink.SharelinkSettingConfigGetter;
|
||||
import run.halo.sharelink.model.VerifiedDownloader;
|
||||
import run.halo.sharelink.util.EmailUtils;
|
||||
|
||||
/**
|
||||
* Unified "is this email verified" check for the download flow. Looks up this plugin's
|
||||
* own {@link VerifiedDownloader} registry first; when {@code trustCommentVerified} is
|
||||
* enabled it also honors the comment plugin's {@code VerifiedCommenter} registry
|
||||
* (resolved at runtime by GVK + SHA-256 name, degrading gracefully when the comment
|
||||
* plugin is absent).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class VerifiedEmailService {
|
||||
|
||||
private static final GroupVersionKind COMMENT_VERIFIED_COMMENTER_GVK =
|
||||
GroupVersionKind.fromAPIVersionAndKind("commentwidget.halo.run/v1alpha1",
|
||||
"VerifiedCommenter");
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
|
||||
public Mono<Boolean> isVerified(String email) {
|
||||
var normalizedEmail = EmailUtils.normalizeEmail(email);
|
||||
if (normalizedEmail == null) {
|
||||
return Mono.just(false);
|
||||
}
|
||||
// Look up by the deterministic metadata name (SHA-256 of the normalized email).
|
||||
return client.fetch(VerifiedDownloader.class, EmailUtils.sha256Hex(normalizedEmail))
|
||||
.map(downloader -> downloader.getSpec() != null
|
||||
&& normalizedEmail.equals(downloader.getSpec().getEmail()))
|
||||
.defaultIfEmpty(false)
|
||||
.flatMap(verified -> verified ? Mono.just(true)
|
||||
: isCommentVerified(normalizedEmail));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the comment plugin's verified registry. Any failure (plugin absent, scheme
|
||||
* unregistered, store error) degrades to "not verified" without affecting the main
|
||||
* flow.
|
||||
*/
|
||||
private Mono<Boolean> isCommentVerified(String normalizedEmail) {
|
||||
return settingConfigGetter.getEmailVerifyConfig()
|
||||
.flatMap(config -> {
|
||||
if (!config.isTrustCommentVerified()) {
|
||||
return Mono.just(false);
|
||||
}
|
||||
return client.fetch(COMMENT_VERIFIED_COMMENTER_GVK,
|
||||
EmailUtils.sha256Hex(normalizedEmail))
|
||||
.map(unstructured -> true)
|
||||
.defaultIfEmpty(false)
|
||||
.onErrorResume(e -> {
|
||||
log.debug("查询评论插件已验证邮箱失败,按未验证处理: {}", normalizedEmail, e);
|
||||
return Mono.just(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers (or refreshes) the email in this plugin's own verified registry.
|
||||
*/
|
||||
public Mono<Void> recordVerified(String email, String ip) {
|
||||
var normalizedEmail = EmailUtils.normalizeEmail(email);
|
||||
if (normalizedEmail == null) {
|
||||
return Mono.empty();
|
||||
}
|
||||
var name = EmailUtils.sha256Hex(normalizedEmail);
|
||||
var now = Instant.now();
|
||||
// Retried once to tolerate a concurrent registration of the same email.
|
||||
return Mono.defer(() -> upsertVerified(name, normalizedEmail, now))
|
||||
.retry(1)
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<VerifiedDownloader> upsertVerified(String name, String email, Instant now) {
|
||||
return client.fetch(VerifiedDownloader.class, name)
|
||||
.flatMap(existing -> {
|
||||
existing.getSpec().setLastVerifiedAt(now);
|
||||
return client.update(existing);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> {
|
||||
var downloader = new VerifiedDownloader();
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(name);
|
||||
downloader.setMetadata(metadata);
|
||||
var spec = new VerifiedDownloader.Spec();
|
||||
spec.setEmail(email);
|
||||
spec.setFirstVerifiedAt(now);
|
||||
spec.setLastVerifiedAt(now);
|
||||
downloader.setSpec(spec);
|
||||
return client.create(downloader);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: ReasonType
|
||||
metadata:
|
||||
name: sharelink-download-verification
|
||||
spec:
|
||||
displayName: "资源下载邮箱验证"
|
||||
description: "访客下载需要邮箱验证的资源时,向其邮箱发送数字验证码。"
|
||||
properties:
|
||||
- name: username
|
||||
type: string
|
||||
- name: code
|
||||
type: string
|
||||
- name: expirationAtMinutes
|
||||
type: string
|
||||
---
|
||||
apiVersion: notification.halo.run/v1alpha1
|
||||
kind: NotificationTemplate
|
||||
metadata:
|
||||
name: template-sharelink-download-verification
|
||||
spec:
|
||||
reasonSelector:
|
||||
reasonType: sharelink-download-verification
|
||||
language: default
|
||||
template:
|
||||
title: "资源下载验证码-[(${site.title})]"
|
||||
rawBody: |
|
||||
【[(${site.title})]】你的资源下载验证码是:[(${code})],请在 [(${expirationAtMinutes})] 分钟内完成验证。若不是你本人操作,请忽略。
|
||||
htmlBody: |
|
||||
<div class="notification-content">
|
||||
<div class="head">
|
||||
<p class="honorific" th:text="|${username} 你好:|"></p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p>你正在 [(${site.title})] 下载资源,使用下面的验证码验证邮箱:</p>
|
||||
<div class="verify-code" style="font-size:24px;line-height:24px;color:#333;">
|
||||
<b th:text="${code}"></b>
|
||||
</div>
|
||||
<p th:text="|验证码 ${expirationAtMinutes} 分钟内有效。若不是你本人操作,请忽略。|"></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: sharelink-public-apis
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
halo.run/hidden: "true"
|
||||
rbac.authorization.halo.run/aggregate-to-anonymous: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "资源下载管理"
|
||||
rbac.authorization.halo.run/display-name: "Sharelink Public APIs"
|
||||
rules:
|
||||
- apiGroups: [ "api.sharelink.halo.run" ]
|
||||
resources: [ "email-verify/send" ]
|
||||
verbs: [ "create" ]
|
||||
- apiGroups: [ "api.sharelink.halo.run" ]
|
||||
resources: [ "email-verify/check" ]
|
||||
verbs: [ "create" ]
|
||||
- apiGroups: [ "api.sharelink.halo.run" ]
|
||||
resources: [ "downloads/token" ]
|
||||
verbs: [ "create" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: role-template-sharelink-view
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "资源下载管理"
|
||||
rbac.authorization.halo.run/display-name: "下载资源查看"
|
||||
rbac.authorization.halo.run/ui-permissions: '["plugin:sharelink:view"]'
|
||||
rules:
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-resources", "download-records", "references" ]
|
||||
verbs: [ "get", "list" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-records/export" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "get" ]
|
||||
# 引用扫描需要读取已发布文章及其快照内容
|
||||
- apiGroups: [ "content.halo.run" ]
|
||||
resources: [ "posts", "snapshots" ]
|
||||
verbs: [ "get", "list" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: role-template-sharelink-manage
|
||||
labels:
|
||||
halo.run/role-template: "true"
|
||||
annotations:
|
||||
rbac.authorization.halo.run/module: "资源下载管理"
|
||||
rbac.authorization.halo.run/display-name: "下载资源管理"
|
||||
rbac.authorization.halo.run/ui-permissions: '["plugin:sharelink:manage"]'
|
||||
rbac.authorization.halo.run/dependencies: '["role-template-sharelink-view"]'
|
||||
rules:
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-resources" ]
|
||||
verbs: [ "create", "update", "delete" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-records" ]
|
||||
verbs: [ "delete" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "references/refresh" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "create" ]
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: sharelink-settings
|
||||
spec:
|
||||
forms:
|
||||
- group: basic
|
||||
label: 基本设置
|
||||
formSchema:
|
||||
- $formkit: number
|
||||
name: tokenTtlSeconds
|
||||
key: tokenTtlSeconds
|
||||
label: 下载令牌有效期(秒)
|
||||
help: 访客在下载页获取下载令牌后,需在该时间内开始下载。
|
||||
min: 10
|
||||
max: 600
|
||||
value: 60
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: dedupeWindowMinutes
|
||||
key: dedupeWindowMinutes
|
||||
label: 免验证资源下载去重窗口(分钟)
|
||||
help: 不需要邮箱验证的资源,同一 IP 在该时间窗口内重复下载只计 1 次。
|
||||
min: 0
|
||||
max: 1440
|
||||
value: 10
|
||||
validation: required
|
||||
- group: emailVerify
|
||||
label: 邮箱验证
|
||||
formSchema:
|
||||
- $formkit: number
|
||||
name: codeExpireMinutes
|
||||
key: codeExpireMinutes
|
||||
label: 验证码有效期(分钟)
|
||||
min: 1
|
||||
max: 30
|
||||
value: 10
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: resendIntervalSeconds
|
||||
key: resendIntervalSeconds
|
||||
label: 重发间隔(秒)
|
||||
min: 30
|
||||
value: 60
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: dailySendLimitPerEmail
|
||||
key: dailySendLimitPerEmail
|
||||
label: 同一邮箱每日发送上限
|
||||
min: 1
|
||||
max: 50
|
||||
value: 5
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: maxVerifyAttempts
|
||||
key: maxVerifyAttempts
|
||||
label: 验证码最大错误尝试次数
|
||||
min: 1
|
||||
max: 10
|
||||
value: 5
|
||||
validation: required
|
||||
- $formkit: number
|
||||
name: ipHourlySendLimit
|
||||
key: ipHourlySendLimit
|
||||
label: 同一 IP 每小时发送上限
|
||||
min: 1
|
||||
max: 200
|
||||
value: 20
|
||||
validation: required
|
||||
- $formkit: checkbox
|
||||
name: trustCommentVerified
|
||||
key: trustCommentVerified
|
||||
label: 信任评论插件已验证的邮箱
|
||||
help: 开启后,已在评论组件中通过邮箱验证的访客,下载需要验证的资源时无需再次验证(需安装并启用评论组件)。
|
||||
value: true
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><rect id="r4" width="512" height="512" x="0" y="0" rx="0" fill="url(#r5)" stroke="#FFFFFF" stroke-width="0" stroke-opacity="100%" paint-order="stroke"></rect><clipPath id="clip"><use xlink:href="#r4"></use></clipPath><defs><linearGradient id="r5" gradientUnits="userSpaceOnUse" gradientTransform="rotate(-135)" style="transform-origin: center center;"><stop stop-color="#0D6FD8"></stop><stop offset="1" stop-color="#0A89FC"></stop></linearGradient></defs><svg xmlns="http://www.w3.org/2000/svg" width="352" height="352" viewBox="0 0 24 24" x="80" y="80" alignment-baseline="middle" style="color: rgb(255, 255, 255);"><path fill="currentColor" d="M13 12H16L12 16L8 12H11V8H13V12ZM15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z"/></svg></svg>
|
||||
|
After Width: | Height: | Size: 998 B |
@@ -0,0 +1,20 @@
|
||||
apiVersion: plugin.halo.run/v1alpha1
|
||||
kind: Plugin
|
||||
metadata:
|
||||
name: sharelink
|
||||
spec:
|
||||
enabled: true
|
||||
requires: ">=2.22.0"
|
||||
author:
|
||||
name: ssy
|
||||
website: https://git.ali.songshiyu.cn/ssy
|
||||
logo: logo.svg
|
||||
homepage: https://git.ali.songshiyu.cn/ssy/sharelink
|
||||
repo: https://git.ali.songshiyu.cn/ssy/sharelink
|
||||
issues: https://git.ali.songshiyu.cn/ssy/sharelink/issues
|
||||
displayName: "资源下载管理"
|
||||
description: "管理文章中的资源下载链接:下载统计、按资源的邮箱验证、防止附件直链下载、文章引用扫描。"
|
||||
configMapName: sharelink-configmap
|
||||
settingName: sharelink-settings
|
||||
license:
|
||||
- name: "GPL-3.0"
|
||||
@@ -0,0 +1,59 @@
|
||||
# sharelink console UI
|
||||
|
||||
Halo 2.x 插件「资源下载管理」的 console 后台前端(Vue 3 + rsbuild +
|
||||
@halo-dev/ui-plugin-bundler-kit)。工程结构照搬
|
||||
`blog-comment/packages/ui`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build # 产物输出到 ../src/main/resources/console(打进插件 jar)
|
||||
pnpm dev # watch 模式,输出到 ../build/resources/main/console
|
||||
pnpm type-check # vue-tsc 类型检查(构建流程不依赖)
|
||||
```
|
||||
|
||||
## 路由与权限
|
||||
|
||||
- 路由:`/download-manager`,挂在 `Root` 下,菜单组 `content`,priority 52
|
||||
- 权限 meta:`plugin:sharelink:view`(见
|
||||
`src/main/resources/extensions/role-templates.yaml` 的
|
||||
`role-template-sharelink-view`)
|
||||
- 注意:角色的 `ui-permissions` 里还定义了 `plugin:sharelink:manage`,但
|
||||
console 菜单只挂了 view 权限;前端未对 manage 操作做按钮级权限隐藏,
|
||||
写操作是否放行完全由后端 RBAC 决定。
|
||||
|
||||
## API 契约(后端并行开发中)
|
||||
|
||||
Base:`/apis/console.api.sharelink.halo.run/v1alpha1`
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/download-resources` | `{ items: [...], total }`(非标准 ListResult) |
|
||||
| POST | `/download-resources` | 创建,**前端只提交 `{ metadata: { name: '' }, spec }`,name 由后端生成** |
|
||||
| GET/PUT/DELETE | `/download-resources/{name}` | 单资源读写删,PUT 提交完整对象 |
|
||||
| GET | `/download-records?resourceSlug=&page=&size=` | Halo 标准 ListResult |
|
||||
| DELETE | `/download-records/{name}` | 删除单条记录 |
|
||||
| GET | `/download-records/-/export?resourceSlug=` | CSV,前端用 `window.open` 直接导航下载 |
|
||||
| GET | `/references` | `{ slug: [{ postName, title, permalink, editorUrl }] }` |
|
||||
| POST | `/references/-/refresh` | 强制重扫,返回同上 |
|
||||
|
||||
## 前端字段假设(联调时若与后端不一致,以此为准核对)
|
||||
|
||||
1. `DownloadResource.spec`:`slug`、`displayName`、`description`、
|
||||
`attachmentName`、`requireEmailVerify`、`enabled`;`status.downloadCount`;
|
||||
`stats.downloaderCount`、`stats.referenceCount`。其中 `stats` 是 Halo
|
||||
自定义资源里不常见的顶层字段(与 `status` 平级),若后端改为放进
|
||||
`status`,需同步修改 `src/types.ts` 和 `DownloadManager.vue` 的两处取值。
|
||||
2. 列表 GET `/download-resources` 返回 `{ items, total }`,不带分页参数,
|
||||
前端一次性拉全量。
|
||||
3. `references` 的 key 是 **slug**(不是 metadata.name)。
|
||||
4. 引用项 `editorUrl` / `permalink` 均为可直接打开的相对或绝对路径,
|
||||
前端 `target="_blank"` 打开。
|
||||
5. 下载记录 `spec.downloadedAt` 为 ISO 时间字符串;`spec.email` 可能为空
|
||||
(匿名下载)。
|
||||
6. CSV 导出端点接受 session cookie 直接 GET 导航,无需 axios blob 下载。
|
||||
7. 附件选择:`AttachmentSelectorModal` 是 Halo Console **全局注册**的业务
|
||||
组件(不从 `@halo-dev/components` 导出),`v-model:visible` 控制显隐,
|
||||
`@select` 回调附件数组,前端存 `attachment.metadata.name` 到
|
||||
`spec.attachmentName`。
|
||||
@@ -0,0 +1,23 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
|
||||
// AttachmentSelectorModal 是 Halo Console 全局注册的业务组件(非 @halo-dev/components 导出),
|
||||
// 见 https://docs.halo.run/developer-guide/plugin/api-reference/ui/components/attachment-selector-modal/
|
||||
declare module 'vue' {
|
||||
interface GlobalComponents {
|
||||
AttachmentSelectorModal: import('vue').DefineComponent<
|
||||
{ visible?: boolean },
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
import('vue').ComponentOptionsMixin,
|
||||
import('vue').ComponentOptionsMixin,
|
||||
{
|
||||
'update:visible': (value: boolean) => void;
|
||||
select: (attachments: unknown[]) => void;
|
||||
}
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "sharelink-console-ui",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"dev": "rsbuild build --watch --env-mode=development",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@halo-dev/api-client": "2.23.0",
|
||||
"@halo-dev/components": "^2.21.0",
|
||||
"@halo-dev/ui-shared": "^2.22.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@halo-dev/ui-plugin-bundler-kit": "^2.21.2",
|
||||
"@rsbuild/core": "^2.0.3",
|
||||
"@rsbuild/plugin-vue": "^1.2.7",
|
||||
"@types/node": "^20.19.24",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { rsbuildConfig } from '@halo-dev/ui-plugin-bundler-kit';
|
||||
import { pluginVue } from '@rsbuild/plugin-vue';
|
||||
|
||||
const MANIFEST_PATH = '../src/main/resources/plugin.yaml';
|
||||
const OUT_DIR_PROD = '../src/main/resources/console';
|
||||
const OUT_DIR_DEV = '../build/resources/main/console';
|
||||
|
||||
export default rsbuildConfig({
|
||||
manifestPath: MANIFEST_PATH,
|
||||
rsbuild: ({ envMode }) => {
|
||||
const isProduction = envMode === 'production';
|
||||
const outDir = isProduction ? OUT_DIR_PROD : OUT_DIR_DEV;
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': './src',
|
||||
},
|
||||
},
|
||||
plugins: [pluginVue()],
|
||||
output: {
|
||||
distPath: {
|
||||
root: outDir,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { axiosInstance } from '@halo-dev/api-client';
|
||||
import type {
|
||||
DownloadResource,
|
||||
DownloadResourceList,
|
||||
DownloadResourceSpec,
|
||||
DownloadRecord,
|
||||
ListResult,
|
||||
ReferencesMap,
|
||||
} from '@/types';
|
||||
|
||||
export const API_BASE = '/apis/console.api.sharelink.halo.run/v1alpha1';
|
||||
|
||||
// 后端资源 VO 为扁平结构,这里统一归一化为 Halo 风格结构供组件使用
|
||||
interface ResourceVo {
|
||||
name: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
attachmentName?: string;
|
||||
requireEmailVerify?: boolean;
|
||||
enabled?: boolean;
|
||||
downloadUrl?: string;
|
||||
creationTimestamp?: string;
|
||||
stats?: {
|
||||
downloadCount?: number;
|
||||
downloaderCount?: number;
|
||||
referenceCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResource(vo: ResourceVo): DownloadResource {
|
||||
return {
|
||||
metadata: { name: vo.name, creationTimestamp: vo.creationTimestamp },
|
||||
spec: {
|
||||
slug: vo.slug,
|
||||
displayName: vo.displayName,
|
||||
description: vo.description,
|
||||
attachmentName: vo.attachmentName,
|
||||
requireEmailVerify: vo.requireEmailVerify,
|
||||
enabled: vo.enabled,
|
||||
},
|
||||
status: { downloadCount: vo.stats?.downloadCount ?? 0 },
|
||||
stats: {
|
||||
downloaderCount: vo.stats?.downloaderCount ?? 0,
|
||||
referenceCount: vo.stats?.referenceCount ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function listResources(): Promise<DownloadResourceList> {
|
||||
const { data } = await axiosInstance.get<ResourceVo[]>(
|
||||
`${API_BASE}/download-resources`
|
||||
);
|
||||
const items = (data ?? []).map(normalizeResource);
|
||||
return { items, total: items.length };
|
||||
}
|
||||
|
||||
// 后端创建/更新均接收扁平的 spec 字段(ResourceRequest)
|
||||
export async function createResource(
|
||||
spec: DownloadResourceSpec
|
||||
): Promise<DownloadResource> {
|
||||
const { data } = await axiosInstance.post<ResourceVo>(
|
||||
`${API_BASE}/download-resources`,
|
||||
spec
|
||||
);
|
||||
return normalizeResource(data);
|
||||
}
|
||||
|
||||
export async function getResource(name: string): Promise<DownloadResource> {
|
||||
const { data } = await axiosInstance.get<ResourceVo>(
|
||||
`${API_BASE}/download-resources/${name}`
|
||||
);
|
||||
return normalizeResource(data);
|
||||
}
|
||||
|
||||
export async function updateResource(
|
||||
name: string,
|
||||
spec: DownloadResourceSpec
|
||||
): Promise<DownloadResource> {
|
||||
const { data } = await axiosInstance.put<ResourceVo>(
|
||||
`${API_BASE}/download-resources/${name}`,
|
||||
spec
|
||||
);
|
||||
return normalizeResource(data);
|
||||
}
|
||||
|
||||
export async function deleteResource(name: string): Promise<void> {
|
||||
await axiosInstance.delete(`${API_BASE}/download-resources/${name}`);
|
||||
}
|
||||
|
||||
export async function listRecords(params: {
|
||||
resourceSlug?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<ListResult<DownloadRecord>> {
|
||||
const { data } = await axiosInstance.get<ListResult<DownloadRecord>>(
|
||||
`${API_BASE}/download-records`,
|
||||
{ params }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteRecord(name: string): Promise<void> {
|
||||
await axiosInstance.delete(`${API_BASE}/download-records/${name}`);
|
||||
}
|
||||
|
||||
// CSV 导出走浏览器直接导航(console 端点接受 session cookie)
|
||||
export function recordsExportUrl(resourceSlug?: string): string {
|
||||
const query = resourceSlug
|
||||
? `?resourceSlug=${encodeURIComponent(resourceSlug)}`
|
||||
: '';
|
||||
return `${API_BASE}/download-records/-/export${query}`;
|
||||
}
|
||||
|
||||
export async function getReferences(): Promise<ReferencesMap> {
|
||||
const { data } = await axiosInstance.get<ReferencesMap>(
|
||||
`${API_BASE}/references`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshReferences(): Promise<ReferencesMap> {
|
||||
const { data } = await axiosInstance.post<ReferencesMap>(
|
||||
`${API_BASE}/references/-/refresh`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
deleteRecord,
|
||||
listRecords,
|
||||
recordsExportUrl,
|
||||
} from '@/api';
|
||||
import type { DownloadRecord, DownloadResource } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
IconDeleteBin,
|
||||
IconRefreshLine,
|
||||
IconRiUpload2Fill,
|
||||
Toast,
|
||||
VButton,
|
||||
VEmpty,
|
||||
VLoading,
|
||||
VModal,
|
||||
VPagination,
|
||||
VSpace,
|
||||
} from '@halo-dev/components';
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
resource: DownloadResource | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const records = ref<DownloadRecord[]>([]);
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const size = ref(20);
|
||||
const total = ref(0);
|
||||
|
||||
// 后端删除为异步落库,短时间内列表仍可能返回已删项,在此窗口内过滤掉
|
||||
const pendingDeletes = new Set<string>();
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
const err = e as {
|
||||
response?: { data?: { detail?: string; title?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return (
|
||||
err?.response?.data?.detail ||
|
||||
err?.response?.data?.title ||
|
||||
err?.message ||
|
||||
'请求失败,请稍后重试'
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso?: string): string {
|
||||
if (!iso) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
return date.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
async function fetchRecords() {
|
||||
if (!props.resource) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listRecords({
|
||||
resourceSlug: props.resource.spec.slug,
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
});
|
||||
records.value = (data.items ?? []).filter(
|
||||
(r) => !pendingDeletes.has(r.metadata.name)
|
||||
);
|
||||
total.value = data.total ?? 0;
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
// 直接导航下载(console 端点接受 session cookie)
|
||||
window.open(recordsExportUrl(props.resource?.spec.slug), '_blank');
|
||||
}
|
||||
|
||||
function handleDelete(record: DownloadRecord) {
|
||||
Dialog.warning({
|
||||
title: '删除下载记录',
|
||||
description: '确定要删除这条下载记录吗?删除后不可恢复。',
|
||||
confirmType: 'danger',
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteRecord(record.metadata.name);
|
||||
// 删除当前页最后一条时回退一页
|
||||
if (records.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
}
|
||||
pendingDeletes.add(record.metadata.name);
|
||||
setTimeout(() => pendingDeletes.delete(record.metadata.name), 3000);
|
||||
records.value = records.value.filter(
|
||||
(r) => r.metadata.name !== record.metadata.name
|
||||
);
|
||||
Toast.success('记录已删除');
|
||||
fetchRecords();
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.resource?.metadata.name],
|
||||
([visible]) => {
|
||||
if (visible) {
|
||||
page.value = 1;
|
||||
fetchRecords();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
:visible="visible"
|
||||
:title="`下载记录${resource ? ` - ${resource.spec.displayName}` : ''}`"
|
||||
:width="960"
|
||||
mount-to-body
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
@close="emit('update:visible', false)"
|
||||
>
|
||||
<div class="records-toolbar">
|
||||
<div class="text-sm text-gray-500">共 {{ total }} 条记录</div>
|
||||
<VSpace>
|
||||
<VButton size="sm" type="default" @click="handleExport">
|
||||
<template #icon>
|
||||
<IconRiUpload2Fill />
|
||||
</template>
|
||||
导出 CSV
|
||||
</VButton>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="secondary"
|
||||
:loading="loading"
|
||||
@click="fetchRecords"
|
||||
>
|
||||
<template #icon>
|
||||
<IconRefreshLine />
|
||||
</template>
|
||||
刷新
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</div>
|
||||
|
||||
<VLoading v-if="loading" />
|
||||
<VEmpty
|
||||
v-else-if="records.length === 0"
|
||||
title="暂无下载记录"
|
||||
message="该资源还没有产生下载"
|
||||
/>
|
||||
<div v-else class="table-wrapper">
|
||||
<table class="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>邮箱</th>
|
||||
<th>IP</th>
|
||||
<th>User-Agent</th>
|
||||
<th class="w-24">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in records" :key="record.metadata.name">
|
||||
<td>{{ formatTime(record.spec.downloadedAt) }}</td>
|
||||
<td>{{ record.spec.email || '匿名' }}</td>
|
||||
<td>{{ record.spec.ip || '-' }}</td>
|
||||
<td class="ua-cell" :title="record.spec.userAgent">
|
||||
{{ record.spec.userAgent || '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="danger"
|
||||
ghost
|
||||
@click="handleDelete(record)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconDeleteBin />
|
||||
</template>
|
||||
删除
|
||||
</VButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="total > 0" class="records-pagination">
|
||||
<VPagination
|
||||
v-model:page="page"
|
||||
v-model:size="size"
|
||||
:total="total"
|
||||
:size-options="[20, 50, 100]"
|
||||
show-total
|
||||
@change="fetchRecords"
|
||||
/>
|
||||
</div>
|
||||
</VModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.records-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.records-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.records-table th,
|
||||
.records-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.records-table th {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.ua-cell {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.records-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,354 @@
|
||||
<script lang="ts" setup>
|
||||
import { createResource, updateResource } from '@/api';
|
||||
import type { DownloadResource, DownloadResourceSpec } from '@/types';
|
||||
import { Toast, VButton, VModal, VSwitch } from '@halo-dev/components';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
// AttachmentSelectorModal 选中的附件对象(只取需要的字段,结构向后兼容)
|
||||
interface PickedAttachment {
|
||||
metadata?: { name?: string };
|
||||
spec?: {
|
||||
displayName?: string;
|
||||
size?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
resource: DownloadResource | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'saved', resource: DownloadResource, created: boolean): void;
|
||||
}>();
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
const isEdit = computed(() => props.resource !== null);
|
||||
|
||||
const form = ref<DownloadResourceSpec>({
|
||||
slug: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
attachmentName: '',
|
||||
requireEmailVerify: false,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// 本次从附件选择器里选到的附件(用于展示文件名 + 大小)
|
||||
const pickedAttachment = ref<PickedAttachment | null>(null);
|
||||
const attachmentSelectorVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
pickedAttachment.value = null;
|
||||
if (props.resource) {
|
||||
const spec = props.resource.spec;
|
||||
form.value = {
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
description: spec.description ?? '',
|
||||
attachmentName: spec.attachmentName ?? '',
|
||||
requireEmailVerify: spec.requireEmailVerify ?? false,
|
||||
enabled: spec.enabled ?? true,
|
||||
};
|
||||
} else {
|
||||
form.value = {
|
||||
slug: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
attachmentName: '',
|
||||
requireEmailVerify: false,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
|
||||
function formatSize(size?: number): string {
|
||||
if (!size || size <= 0) {
|
||||
return '';
|
||||
}
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
// 从附件文件名派生 slug 建议值:取主文件名,转小写,非法字符折叠为 -
|
||||
function deriveSlug(filename: string): string {
|
||||
const base = filename.replace(/\.[^.]+$/, '');
|
||||
const slug = base
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64)
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return SLUG_PATTERN.test(slug) ? slug : '';
|
||||
}
|
||||
|
||||
function randomSlug(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function handleAttachmentSelect(attachments: unknown[]) {
|
||||
const attachment = attachments?.[0] as PickedAttachment | undefined;
|
||||
const name = attachment?.metadata?.name;
|
||||
if (!attachment || !name) {
|
||||
return;
|
||||
}
|
||||
pickedAttachment.value = attachment;
|
||||
form.value.attachmentName = name;
|
||||
// 新建且 slug 为空时,从文件名派生建议值;派生失败给随机短串
|
||||
if (!isEdit.value && !form.value.slug) {
|
||||
const displayName = attachment.spec?.displayName ?? name;
|
||||
form.value.slug = deriveSlug(displayName) || randomSlug();
|
||||
}
|
||||
attachmentSelectorVisible.value = false;
|
||||
}
|
||||
|
||||
const attachmentDisplay = computed(() => {
|
||||
if (pickedAttachment.value) {
|
||||
const name =
|
||||
pickedAttachment.value.spec?.displayName ??
|
||||
pickedAttachment.value.metadata?.name ??
|
||||
'';
|
||||
const size = formatSize(pickedAttachment.value.spec?.size);
|
||||
return size ? `${name}(${size})` : name;
|
||||
}
|
||||
return form.value.attachmentName || '';
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
const displayName = form.value.displayName.trim();
|
||||
const slug = form.value.slug.trim();
|
||||
if (!displayName) {
|
||||
Toast.warning('请填写资源名称');
|
||||
return;
|
||||
}
|
||||
if (!slug) {
|
||||
Toast.warning('请填写 slug');
|
||||
return;
|
||||
}
|
||||
if (!isEdit.value && !SLUG_PATTERN.test(slug)) {
|
||||
Toast.warning(
|
||||
'slug 只能包含小写字母、数字和中划线,且必须以字母或数字开头(最长 64 位)'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEdit.value && props.resource) {
|
||||
const payload: DownloadResourceSpec = {
|
||||
...props.resource.spec,
|
||||
displayName,
|
||||
description: form.value.description?.trim() ?? '',
|
||||
attachmentName: form.value.attachmentName,
|
||||
requireEmailVerify: form.value.requireEmailVerify,
|
||||
enabled: form.value.enabled,
|
||||
};
|
||||
const saved = await updateResource(props.resource.metadata.name, payload);
|
||||
Toast.success('资源已更新');
|
||||
emit('saved', saved, false);
|
||||
} else {
|
||||
const saved = await createResource({
|
||||
slug,
|
||||
displayName,
|
||||
description: form.value.description?.trim() ?? '',
|
||||
attachmentName: form.value.attachmentName,
|
||||
requireEmailVerify: form.value.requireEmailVerify,
|
||||
enabled: form.value.enabled,
|
||||
});
|
||||
Toast.success('资源已创建');
|
||||
emit('saved', saved, true);
|
||||
}
|
||||
close();
|
||||
} catch (e) {
|
||||
const err = e as {
|
||||
response?: { data?: { detail?: string; title?: string } };
|
||||
message?: string;
|
||||
};
|
||||
Toast.error(
|
||||
err?.response?.data?.detail ||
|
||||
err?.response?.data?.title ||
|
||||
err?.message ||
|
||||
'保存失败,请稍后重试'
|
||||
);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
:visible="visible"
|
||||
:title="isEdit ? '编辑下载资源' : '新建下载资源'"
|
||||
:width="640"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
@close="close"
|
||||
>
|
||||
<div class="resource-form">
|
||||
<div class="form-item form-item--column">
|
||||
<label class="form-label">
|
||||
资源名称 <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.displayName"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="例如:产品白皮书 PDF"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item form-item--column">
|
||||
<label class="form-label">
|
||||
slug <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
class="form-input"
|
||||
type="text"
|
||||
:disabled="isEdit"
|
||||
placeholder="例如:whitepaper-2024(下载链接为 /download/{slug})"
|
||||
/>
|
||||
<div v-if="isEdit" class="form-hint">slug 创建后不可修改</div>
|
||||
<div v-else class="form-hint">
|
||||
小写字母、数字、中划线,以字母或数字开头,最长 64 位
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item form-item--column">
|
||||
<label class="form-label">描述</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
placeholder="可选,资源用途说明"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item form-item--column">
|
||||
<label class="form-label">附件</label>
|
||||
<div class="attachment-picker">
|
||||
<VButton size="sm" type="default" @click="attachmentSelectorVisible = true">
|
||||
选择附件
|
||||
</VButton>
|
||||
<span v-if="attachmentDisplay" class="attachment-name" :title="form.attachmentName">
|
||||
{{ attachmentDisplay }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400">未选择</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<span class="form-label">需要邮箱验证</span>
|
||||
<VSwitch v-model="form.requireEmailVerify" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<span class="form-label">启用</span>
|
||||
<VSwitch v-model="form.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<VButton type="primary" :loading="saving" @click="handleSave">
|
||||
保存
|
||||
</VButton>
|
||||
<VButton type="default" @click="close">取消</VButton>
|
||||
</template>
|
||||
</VModal>
|
||||
|
||||
<AttachmentSelectorModal
|
||||
v-model:visible="attachmentSelectorVisible"
|
||||
@select="handleAttachmentSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.resource-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-item--column {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.attachment-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
max-width: 380px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { IconArrowDownCircleLine } from '@halo-dev/components';
|
||||
import { definePlugin } from '@halo-dev/ui-shared';
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
export default definePlugin({
|
||||
components: {},
|
||||
routes: [
|
||||
{
|
||||
parentName: 'Root',
|
||||
route: {
|
||||
path: '/download-manager',
|
||||
name: 'DownloadManager',
|
||||
component: () => import('@/views/DownloadManager.vue'),
|
||||
meta: {
|
||||
title: '下载管理',
|
||||
permissions: ['plugin:sharelink:view'],
|
||||
menu: {
|
||||
name: '下载管理',
|
||||
group: 'content',
|
||||
icon: markRaw(IconArrowDownCircleLine),
|
||||
priority: 52,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
extensionPoints: {},
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// 后端 API 契约类型,字段假设见 ui/README.md
|
||||
export interface DownloadResourceSpec {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
attachmentName?: string;
|
||||
requireEmailVerify?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadResource {
|
||||
apiVersion?: string;
|
||||
kind?: string;
|
||||
metadata: {
|
||||
name: string;
|
||||
creationTimestamp?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
spec: DownloadResourceSpec;
|
||||
status?: {
|
||||
downloadCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
stats?: {
|
||||
downloaderCount?: number;
|
||||
referenceCount?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DownloadResourceList {
|
||||
items: DownloadResource[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DownloadRecord {
|
||||
metadata: {
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
spec: {
|
||||
resourceSlug: string;
|
||||
email?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
downloadedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Halo 标准 ListResult 结构
|
||||
export interface ListResult<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
hasNext?: boolean;
|
||||
hasPrevious?: boolean;
|
||||
totalPages?: number;
|
||||
}
|
||||
|
||||
export interface ReferenceInfo {
|
||||
postName: string;
|
||||
title: string;
|
||||
permalink: string;
|
||||
editorUrl: string;
|
||||
}
|
||||
|
||||
// slug -> 引用文章列表
|
||||
export type ReferencesMap = Record<string, ReferenceInfo[]>;
|
||||
@@ -0,0 +1,512 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
createResource,
|
||||
deleteResource,
|
||||
getReferences,
|
||||
listResources,
|
||||
refreshReferences,
|
||||
updateResource,
|
||||
} from '@/api';
|
||||
import ResourceFormModal from '@/components/ResourceFormModal.vue';
|
||||
import RecordsDrawer from '@/components/RecordsDrawer.vue';
|
||||
import type { DownloadResource, ReferencesMap } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
IconAddCircle,
|
||||
IconClipboardLine,
|
||||
IconDeleteBin,
|
||||
IconExternalLinkLine,
|
||||
IconRefreshLine,
|
||||
IconRiPencilFill,
|
||||
Toast,
|
||||
VButton,
|
||||
VCard,
|
||||
VEmpty,
|
||||
VLoading,
|
||||
VPageHeader,
|
||||
VSpace,
|
||||
VSwitch,
|
||||
VTag,
|
||||
} from '@halo-dev/components';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const resources = ref<DownloadResource[]>([]);
|
||||
const loading = ref(false);
|
||||
const references = ref<ReferencesMap>({});
|
||||
const referencesLoading = ref(false);
|
||||
const refreshingScan = ref(false);
|
||||
|
||||
// 后端删除为异步落库,短时间内列表仍可能返回已删项,在此窗口内过滤掉
|
||||
const pendingDeletes = new Set<string>();
|
||||
|
||||
// 展开引用文章列表的 slug 集合
|
||||
const expandedSlugs = ref<Set<string>>(new Set());
|
||||
|
||||
const formModalVisible = ref(false);
|
||||
const editingResource = ref<DownloadResource | null>(null);
|
||||
|
||||
const recordsVisible = ref(false);
|
||||
const recordsResource = ref<DownloadResource | null>(null);
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
const err = e as {
|
||||
response?: { data?: { detail?: string; title?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return (
|
||||
err?.response?.data?.detail ||
|
||||
err?.response?.data?.title ||
|
||||
err?.message ||
|
||||
'请求失败,请稍后重试'
|
||||
);
|
||||
}
|
||||
|
||||
function downloadUrl(slug: string): string {
|
||||
return `${window.location.origin}/download/${slug}`;
|
||||
}
|
||||
|
||||
async function copyDownloadUrl(slug: string) {
|
||||
const url = downloadUrl(slug);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
Toast.success('下载链接已复制');
|
||||
} catch {
|
||||
// 剪贴板 API 不可用时的降级方案
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = url;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
Toast.success('下载链接已复制');
|
||||
} catch {
|
||||
Toast.error('复制失败,请手动复制');
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchResources() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listResources();
|
||||
resources.value = (data.items ?? []).filter(
|
||||
(r) => !pendingDeletes.has(r.metadata.name)
|
||||
);
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchReferences() {
|
||||
referencesLoading.value = true;
|
||||
try {
|
||||
references.value = (await getReferences()) ?? {};
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
referencesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshScan() {
|
||||
refreshingScan.value = true;
|
||||
try {
|
||||
references.value = (await refreshReferences()) ?? {};
|
||||
Toast.success('引用扫描已刷新');
|
||||
fetchResources();
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
refreshingScan.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(slug: string) {
|
||||
const next = new Set(expandedSlugs.value);
|
||||
if (next.has(slug)) {
|
||||
next.delete(slug);
|
||||
} else {
|
||||
next.add(slug);
|
||||
}
|
||||
expandedSlugs.value = next;
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
editingResource.value = null;
|
||||
formModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(resource: DownloadResource) {
|
||||
editingResource.value = resource;
|
||||
formModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleOpenRecords(resource: DownloadResource) {
|
||||
recordsResource.value = resource;
|
||||
recordsVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(resource: DownloadResource, value: boolean) {
|
||||
const previous = resource.spec.enabled;
|
||||
resource.spec.enabled = value;
|
||||
try {
|
||||
await updateResource(resource.metadata.name, {
|
||||
...resource.spec,
|
||||
enabled: value,
|
||||
});
|
||||
Toast.success(value ? '已启用' : '已停用');
|
||||
} catch (e) {
|
||||
resource.spec.enabled = previous;
|
||||
Toast.error(errorMessage(e));
|
||||
fetchResources();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(resource: DownloadResource) {
|
||||
Dialog.warning({
|
||||
title: '删除下载资源',
|
||||
description: `确定要删除资源「${resource.spec.displayName}」吗?删除后下载链接 /download/${resource.spec.slug} 将立即失效。`,
|
||||
confirmType: 'danger',
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteResource(resource.metadata.name);
|
||||
pendingDeletes.add(resource.metadata.name);
|
||||
setTimeout(
|
||||
() => pendingDeletes.delete(resource.metadata.name),
|
||||
3000
|
||||
);
|
||||
resources.value = resources.value.filter(
|
||||
(r) => r.metadata.name !== resource.metadata.name
|
||||
);
|
||||
Toast.success(`已删除资源「${resource.spec.displayName}」`);
|
||||
fetchResources();
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaved(resource: DownloadResource, created: boolean) {
|
||||
fetchResources();
|
||||
if (created && resource.spec.enabled) {
|
||||
Toast.success(`下载链接:/download/${resource.spec.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchResources();
|
||||
fetchReferences();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPageHeader title="下载管理" />
|
||||
|
||||
<div class="download-manager-page m-4">
|
||||
<VCard>
|
||||
<div class="card-toolbar">
|
||||
<div class="text-sm text-gray-500">
|
||||
共 {{ resources.length }} 个下载资源
|
||||
</div>
|
||||
<VSpace>
|
||||
<VButton size="sm" type="primary" @click="handleCreate">
|
||||
<template #icon>
|
||||
<IconAddCircle />
|
||||
</template>
|
||||
新建资源
|
||||
</VButton>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="secondary"
|
||||
:loading="refreshingScan"
|
||||
@click="handleRefreshScan"
|
||||
>
|
||||
<template #icon>
|
||||
<IconRefreshLine />
|
||||
</template>
|
||||
刷新引用扫描
|
||||
</VButton>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="default"
|
||||
:loading="loading || referencesLoading"
|
||||
@click="
|
||||
fetchResources();
|
||||
fetchReferences();
|
||||
"
|
||||
>
|
||||
刷新
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</div>
|
||||
|
||||
<VLoading v-if="loading" />
|
||||
<VEmpty
|
||||
v-else-if="resources.length === 0"
|
||||
title="暂无下载资源"
|
||||
message="点击右上角「新建资源」创建第一个资源下载链接"
|
||||
/>
|
||||
<div v-else class="table-wrapper">
|
||||
<table class="resource-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>下载链接</th>
|
||||
<th>附件</th>
|
||||
<th>邮箱验证</th>
|
||||
<th>启用</th>
|
||||
<th>引用文章</th>
|
||||
<th>下载数</th>
|
||||
<th>下载人数</th>
|
||||
<th class="w-56">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in resources" :key="item.metadata.name">
|
||||
<tr>
|
||||
<td class="font-medium">
|
||||
<div>{{ item.spec.displayName }}</div>
|
||||
<div
|
||||
v-if="item.spec.description"
|
||||
class="text-xs text-gray-400 desc-line"
|
||||
:title="item.spec.description"
|
||||
>
|
||||
{{ item.spec.description }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="link-cell">
|
||||
<code class="link-code">/download/{{ item.spec.slug }}</code>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="default"
|
||||
ghost
|
||||
@click="copyDownloadUrl(item.spec.slug)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconClipboardLine />
|
||||
</template>
|
||||
复制
|
||||
</VButton>
|
||||
</div>
|
||||
</td>
|
||||
<td class="cell-ellipsis" :title="item.spec.attachmentName">
|
||||
{{ item.spec.attachmentName || '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<VTag :theme="item.spec.requireEmailVerify ? 'primary' : 'default'">
|
||||
{{ item.spec.requireEmailVerify ? '需要' : '不需要' }}
|
||||
</VTag>
|
||||
</td>
|
||||
<td>
|
||||
<VSwitch
|
||||
:model-value="item.spec.enabled ?? false"
|
||||
@change="(value: boolean) => handleToggleEnabled(item, value)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="default"
|
||||
ghost
|
||||
@click="toggleExpand(item.spec.slug)"
|
||||
>
|
||||
{{ item.stats?.referenceCount ?? 0 }} 篇
|
||||
{{ expandedSlugs.has(item.spec.slug) ? '▲' : '▼' }}
|
||||
</VButton>
|
||||
</td>
|
||||
<td>{{ item.status?.downloadCount ?? 0 }}</td>
|
||||
<td>{{ item.stats?.downloaderCount ?? 0 }}</td>
|
||||
<td>
|
||||
<VSpace>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="default"
|
||||
@click="handleOpenRecords(item)"
|
||||
>
|
||||
记录
|
||||
</VButton>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="secondary"
|
||||
@click="handleEdit(item)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconRiPencilFill />
|
||||
</template>
|
||||
编辑
|
||||
</VButton>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="danger"
|
||||
ghost
|
||||
@click="handleDelete(item)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconDeleteBin />
|
||||
</template>
|
||||
删除
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedSlugs.has(item.spec.slug)" class="refs-row">
|
||||
<td colspan="9">
|
||||
<div
|
||||
v-if="(references[item.spec.slug] ?? []).length === 0"
|
||||
class="text-sm text-gray-400 refs-empty"
|
||||
>
|
||||
暂无文章引用该资源(可在编辑器中插入 /download/{{
|
||||
item.spec.slug
|
||||
}}
|
||||
链接后点击「刷新引用扫描」)
|
||||
</div>
|
||||
<ul v-else class="refs-list">
|
||||
<li
|
||||
v-for="refItem in references[item.spec.slug]"
|
||||
:key="refItem.postName"
|
||||
>
|
||||
<span class="refs-title">{{ refItem.title }}</span>
|
||||
<a
|
||||
class="refs-link"
|
||||
:href="refItem.editorUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconExternalLinkLine /> 编辑器
|
||||
</a>
|
||||
<a
|
||||
class="refs-link"
|
||||
:href="refItem.permalink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconExternalLinkLine /> 访问
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<ResourceFormModal
|
||||
v-model:visible="formModalVisible"
|
||||
:resource="editingResource"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
|
||||
<RecordsDrawer
|
||||
v-model:visible="recordsVisible"
|
||||
:resource="recordsResource"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resource-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.resource-table th,
|
||||
.resource-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.resource-table th {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.link-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link-code {
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cell-ellipsis {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.desc-line {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.refs-row td {
|
||||
background: #fafafa;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.refs-empty {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.refs-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.refs-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.refs-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.refs-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.refs-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||