feat: 支持外部链接类型附件 302 跳转下载
CI / ci (push) Has been cancelled

This commit is contained in:
shirainbown
2026-08-22 23:57:51 +08:00
parent 5007961329
commit 0d7a5ca054
4 changed files with 28 additions and 7 deletions
+7 -1
View File
@@ -118,12 +118,14 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
| **资源名称** * | 显示给访客的名称,也是下载文件的文件名(无扩展名时自动补上附件原扩展名) | | **资源名称** * | 显示给访客的名称,也是下载文件的文件名(无扩展名时自动补上附件原扩展名) |
| **slug** * | 下载链接的标识,如填 `whitepaper-2024`,下载链接就是 `/download/whitepaper-2024`。仅限小写字母、数字、中划线。**创建后不可修改** | | **slug** * | 下载链接的标识,如填 `whitepaper-2024`,下载链接就是 `/download/whitepaper-2024`。仅限小写字母、数字、中划线。**创建后不可修改** |
| **描述** | 可选,显示在下载页标题下方 | | **描述** | 可选,显示在下载页标题下方 |
| **附件** | 点击「选择附件」从附件库选择(也可先上传新附件) | | **附件** | 点击「选择附件」从附件库选择(也可先上传新附件);支持「外部链接」类型的附件(见下方说明) |
| **需要邮箱验证** | 开启后访客必须验证邮箱才能下载 | | **需要邮箱验证** | 开启后访客必须验证邮箱才能下载 |
| **启用** | 停用后下载页和下载链接立即 404,但配置保留 | | **启用** | 停用后下载页和下载链接立即 404,但配置保留 |
保存后回到列表,点击该行的 **「复制」** 按钮即可拿到完整下载链接(含域名),粘贴到文章的任意位置(普通链接、按钮、卡片都可以)。 保存后回到列表,点击该行的 **「复制」** 按钮即可拿到完整下载链接(含域名),粘贴到文章的任意位置(普通链接、按钮、卡片都可以)。
> 💡 **外部链接附件**:如果选择的是「外部链接」类型的附件(文件不在本站存储),访客通过验证、计数完成后,浏览器会被 302 跳转到该外部地址直接下载——文件不经服务器中转,不占带宽;但真实外部地址会暴露给访客,且防直链不适用(详见第十节)。
### 4.2 列表各列含义 ### 4.2 列表各列含义
- **邮箱验证**:该资源是否需要验证(需要 / 不需要) - **邮箱验证**:该资源是否需要验证(需要 / 不需要)
@@ -268,6 +270,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|---|---| |---|---|
| 本地存储策略的附件(你当前的使用方式) | ✅ 直链 404 | | 本地存储策略的附件(你当前的使用方式) | ✅ 直链 404 |
| 外部对象存储(S3/OSS 等)的附件 | ❌ 无法拦截(文件不经 Halo 发出),但下载统计和邮箱验证仍正常 | | 外部对象存储(S3/OSS 等)的附件 | ❌ 无法拦截(文件不经 Halo 发出),但下载统计和邮箱验证仍正常 |
| 外部链接类型的附件 | ❌ 不适用;下载时验证通过后 302 跳转到外部地址,真实地址对访客可见 |
| 同一附件被文章当图片直接引用 | ⚠️ 会被一并拦截,图片变 404! | | 同一附件被文章当图片直接引用 | ⚠️ 会被一并拦截,图片变 404! |
> ⚠️ **最重要的使用纪律**:不要把文章中需要**直接显示**的图片/附件注册为下载资源。注册即等于"此文件只能经下载页获取"。 > ⚠️ **最重要的使用纪律**:不要把文章中需要**直接显示**的图片/附件注册为下载资源。注册即等于"此文件只能经下载页获取"。
@@ -302,3 +305,6 @@ A:正常。同一个人多次下载,次数累加、人数去重。
**Q:对象存储附件能防直链吗?** **Q:对象存储附件能防直链吗?**
A:不能。防直链只对本地存储策略生效;但统计与邮箱验证对所有存储策略都有效。 A:不能。防直链只对本地存储策略生效;但统计与邮箱验证对所有存储策略都有效。
**Q:资源绑的是「外部链接」附件,下载是什么行为?**
A:访客点击下载、(如需)完成邮箱验证并计数后,浏览器直接 302 跳转到外部地址下载,文件不经服务器中转。外部链接失效时跳转会由目标站点报错(不再是插件的 404 页)。
+1 -1
View File
@@ -1 +1 @@
version=1.3.2 version=1.3.3
+1 -1
View File
@@ -1,5 +1,5 @@
plugins { plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
} }
rootProject.name = 'sharelink' rootProject.name = 'sharelink'
@@ -35,10 +35,12 @@ import io.github.shirainbown.sharelink.model.DownloadResource;
* Streams the attachment bytes of a download resource to the visitor. * Streams the attachment bytes of a download resource to the visitor.
* *
* <p>For the local storage policy the file is read directly from disk * <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 * ({work-dir}/attachments/{local-relative-path}); for external-link attachments (the
* through a loopback HTTP request to its permalink (carrying the internal secret header * permalink is an absolute http(s) URL) the visitor is redirected (302) to the external
* so {@code UploadProtectFilter} lets it pass). The body is forwarded as a * URL after the token has been consumed and the download recorded; for other policies
* {@link DataBuffer} stream without buffering the whole file in memory. * 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 @Slf4j
@Component @Component
@@ -156,6 +158,19 @@ public class FileStreamer {
if (StringUtils.isBlank(permalink)) { if (StringUtils.isBlank(permalink)) {
return errorPage(HttpStatus.NOT_FOUND, "附件暂不可用"); return errorPage(HttpStatus.NOT_FOUND, "附件暂不可用");
} }
// External-link attachment: the permalink is already an absolute URL. Hand the
// download to the browser instead of proxy-fetching it server-side (the proxy
// breaks on expired links, anti-hotlink 403s and un-followed 301/302s).
if (StringUtils.startsWithAny(permalink, "http://", "https://")) {
try {
return ServerResponse.status(HttpStatus.FOUND)
.location(URI.create(permalink))
.cacheControl(CacheControl.noStore())
.build();
} catch (IllegalArgumentException e) {
log.warn("外链附件地址非法,回退为代理抓取: {}", permalink, e);
}
}
final URI fileUri; final URI fileUri;
try { try {
fileUri = externalUrlSupplier.getURL(request.exchange().getRequest()) fileUri = externalUrlSupplier.getURL(request.exchange().getRequest())