疏通学习堵:修复学习通文件分享链接失效

半个月之前,我发现开始有人在我网站反馈了链接失效问题:学习通的下载分享链接全面挂掉。
看来应该是学习通做了一些限制,不让白嫖了。我的一个好哥们站长告诉我:现在学习通限制下载的情况,是 Referer 限制,只允许超星的域名 Referer,空 Referer 和其他域名的 Referer 都会 403。
嗯,有意思,正好今天有时间了,让我研究看看是这么个事。

学习通 Referer 限制的情况

为了研究 Referer 限制是什么情况,我特地做了实验:
在 i.chaoxing.com 的页面下,打开浏览器控制台,执行:

const downloadUrl = "https://mooc1.chaoxing.com/ueditorupload/read?objectId=e375aa8a412cce8cd5ff924bf0fd9a15";

再执行:

window.open(downloadUrl, "_blank");

此时,可以正常下载文件。
但是,如果你尝试不带 Referer :

const a = document.createElement("a");
a.href = downloadUrl;
a.referrerPolicy = "no-referrer";
a.target = "_blank";
a.rel = "noopener noreferrer";
document.body.appendChild(a);
a.click();
a.remove();

则会出来 403 。
这说明,如果我们需要从学习通下载文件的话,必须带上学习通域名的 Referer ,至于说怎么让请求带上正确的 Referer,就要想办法了。

尝试通过浏览器 JS 下载

最开始,我想到的是:既然原网站的下载链接可以在浏览器中正常使用,那么能不能自己写一段 JavaScript,通过 fetch() 获取下载链接,再交给浏览器下载,从而解决 Referer 不正确的问题?
如果这个方案可行,就可以直接制作一个独立的 Web 下载页面,用户无需安装额外软件,也不需要让服务器中转文件。
然而,经过几轮测试,我发现事情并没有这么简单。

浏览器对 Referer 的限制

开始之前,首先需要了解 HTTP 请求中的 Referer 请求头:当用户从网页 A 访问网页 B 时,浏览器可能在请求中携带 Referer,用来告诉服务器这次请求是从哪个页面发起的。
例如:

GET /download/file.zip HTTP/1.1
Host: download.example.com
Referer: https://original.example.com/

一些下载服务器会通过 Referer 判断请求是否来自允许的网站(学习通就是这么干了)。如果来源不符合要求,就返回 HTTP 403 Forbidden。
我遇到的情况正是如此:在学习通的域名通过 JS 控制台发起请求可以正常下载,而在其他页面发起请求,或者不发送 Referer 请求头就出现 403。

于是,我尝试直接通过 JavaScript 修改请求头:

fetch("https://mooc1.chaoxing.com/ueditorupload/read?objectId=e375aa8a412cce8cd5ff924bf0fd9a15", {
  headers: {
    "Referer": "https://i.chaoxing.com/"
  }
});

但是,浏览器并不会允许普通网页脚本通过这种方式任意伪造 Referer,导致仍然发送正常的 Referer,从而被 403,此路不通。

使用 Fetch API 的 referrer 和 referrerPolicy

查阅相关资料后,我发现 Fetch API 提供了两个相关配置:

  • referrer:指定请求使用的引用来源,但不能任意指定第三方域名。
  • referrerPolicy:控制请求发送多少引用来源信息,例如完整 URL、仅来源域名,或者完全不发送。

于是,我直接在 Firefox 中打开 https://example.com/,按下 F12,在开发者工具控制台进行了六组实验。
为了便于观察服务器实际收到的请求头,我使用 https://httpbin.org/anything 作为测试接口,它能够将收到的请求信息以 JSON 形式返回,方便观测结果。
测试代码如下:

(async () => {
  const tests = [
    {
      name: "默认 Referer",
      options: {}
    },
    {
      name: "不发送 Referer",
      options: {
        referrerPolicy: "no-referrer"
      }
    },
    {
      name: "仅发送来源域名",
      options: {
        referrerPolicy: "origin"
      }
    },
    {
      name: "尝试伪造第三方域名",
      options: {
        referrer: "https://google.com/fake",
        referrerPolicy: "unsafe-url"
      }
    },
    {
      name: "尝试伪造同源路径",
      options: {
        referrer: "https://example.com/fake-page",
        referrerPolicy: "unsafe-url"
      }
    },
    {
      name: "尝试直接修改请求头",
      options: {
        headers: {
          "Referer": "https://google.com/fake"
        }
      }
    }
  ];

  for (const test of tests) {
    try {
      const response = await fetch(
        "https://httpbin.org/anything?test=" +
          encodeURIComponent(test.name),
        {
          ...test.options,
          cache: "no-store"
        }
      );

      const data = await response.json();

      console.log(test.name, {
        status: response.status,
        referer: data.headers?.Referer ?? "(未发送)",
        origin: data.headers?.Origin ?? "(未发送)"
      });
    } catch (err) {
      console.warn(test.name, err.message);
    }
  }
})();

实际测试结果如下:

测试方式服务器收到的 Referer结果
默认请求https://example.com/正常
no-referrer未发送成功清除
originhttps://example.com/正常
伪造 google.comhttps://example.com/伪造失败
修改同源路径https://example.com/路径未被发送
直接修改 Headershttps://example.com/修改失败

失败,抬走,下一个。

进一步检查 Request.referrer

为了排除 JavaScript 本身没有正确设置参数的可能,我又测试了 Request 对象:

(async () => {
  const req = new Request(
    "https://httpbin.org/anything",
    {
      referrer: "https://example.com/fake-page?test=123",
      referrerPolicy: "unsafe-url"
    }
  );

  console.log("Request.referrer:", req.referrer);
  console.log("Request.referrerPolicy:", req.referrerPolicy);

  const response = await fetch(req);
  const data = await response.json();

  console.log("服务器收到:", data.headers.Referer);
})();

结果发现:

Request.referrer:
https://example.com/fake-page?test=123

Request.referrerPolicy:
unsafe-url

服务器收到:
https://example.com/

这就很有意思了,JavaScript 确实成功为 Request 对象设置了一个带路径和参数的同源 Referrer,但是浏览器在真正发送跨站请求时,仍然只发送了来源域名。
也就是说,JavaScript 中配置的 Referrer,不一定等于最终发送到服务器的 Referer 请求头。
这种限制不是简单更换 fetch() 的写法就能绕过的,这下确定是此路不通了。

最终结论:纯浏览器 JS 方案不可行

经过这些测试,我确认了以下几点:

  1. 普通网页 JavaScript 可以通过 fetch() 发起跨域 HTTP 请求,但读取响应需要满足 CORS 要求。
  2. JavaScript 可以通过 referrerPolicy 清除或者限制 Referer 信息。
  3. JavaScript 不能在普通网页环境中任意将 Referer 伪造成其他网站的域名。
  4. 即使设置了 Request.referrer,浏览器最终发送的请求头也可能受到隐私策略限制。
  5. 在我的实际下载场景中,直接从原网站打开链接成功,而不发送 Referer 的请求返回了 403。

被卡脖子的地方,是目标下载接口对请求来源的验证,加上普通网页 JavaScript 无法任意控制 Referer 的浏览器安全机制。
这么看,如果坚持使用纯 HTML + JavaScript 页面让浏览器直接连接文件服务器进行下载,在当前下载接口的限制下,是走不通的,这下学习通成学习堵了。
另一个思路是,搭建流量代理,让服务器进行中转下载,但是一些压缩包体积很大,外加网络波动,这样子做既折磨我的服务器也折磨访客。

于是,我开始考虑另一个方向:

既然浏览器不允许 JavaScript 任意修改 Referer,那么为什么不直接使用不受浏览器同源策略约束的命令行下载工具呢?

那就轮到 curl 和 wget 登场表演了。

意外发现:cURL 可以下载!

正当我以为只能通过浏览器扩展或者自己搭建反向代理来解决下载问题的时候,突然想起来 Firefox 的网络请求记录,是可以复制为 cURL 命令的,而且似乎和浏览器进行下载的效果非常像。
Linux 上的 curl 和 wget 本身就是成熟的 HTTP 客户端,而且能够自由设置请求头。最重要的是,它们不受浏览器同源策略和 CORS 机制的限制。
于是,我决定用 curl 再尝试一次。

使用 cURL 指定 Referer

最初的想法非常简单:手动指定与原网站一致的 Referer,然后请求真实下载地址。

curl -L \
  -e "https://i.chaoxing.com/" \
  "https://mooc1.chaoxing.com/ueditorupload/read?objectId=e375aa8a412cce8cd5ff924bf0fd9a15" \
  -o test.zip

这里的几个参数分别是:

  • -L:跟随 HTTP 301、302 等重定向。
  • -e:设置 HTTP 请求中的 Referer。
  • -o:将响应内容保存为指定文件名。

相比浏览器 JavaScript,curl 没有禁止用户自行指定 Referer 的限制,这意味着可以直接向下载服务器发送类似这样的请求:

GET /ueditorupload/read?objectId=e375aa8a412cce8cd5ff924bf0fd9a15 HTTP/1.1
Host: mooc1.chaoxing.com
Referer: https://i.chaoxing.com/

这正是之前通过普通网页 JavaScript 一直无法实现的操作!

测试发现,只要请求满足服务器的验证要求,curl 就能够正常获取压缩包,这说明文件资源本身并没有只能通过浏览器下载的技术限制。真正的问题是,普通网页 JavaScript 不能任意控制浏览器发出的请求头。

解决文件名问题

虽然文件能够下载了,但很快又遇到了另一个问题:文件名。

原网站通过浏览器下载时,可以正常显示压缩包原本的文件名,而直接使用 curl -o test.zip 显然只能保存成手动指定的名称。
进一步了解后发现,浏览器通常会根据服务器返回的 Content-Disposition 响应头确定文件名。
例如:

Content-Disposition: attachment; filename="example.zip"

或者采用 UTF-8 编码的文件名:

Content-Disposition: attachment; filename*=UTF-8''example.zip

因此,如果希望 curl 使用服务器提供的文件名,可以尝试:

curl -fL -OJ \
  -e "https://i.chaoxing.com/" \
  "https://mooc1.chaoxing.com/ueditorupload/read?objectId=e375aa8a412cce8cd5ff924bf0fd9a15"

其中:

  • -f:遇到 HTTP 400、403、404 等错误时返回失败,而不是把错误页面当作压缩包保存。
  • -O:使用远程文件名。
  • -J:尝试根据 Content-Disposition 响应头确定文件名。

不过这里有个细节:curl -J 对采用 filename*= 的 UTF-8 文件名支持有限。如果服务器仅返回这种形式,可能无法正确保留中文文件名。

所以,我又考虑了另一个 Linux 上常见的下载工具:wget。

Wget 也是一种选择

GNU Wget 同样支持指定 Referer:

wget \
  --referer="https://i.chaoxing.com/" \
  --content-disposition \
  "https://mooc1.chaoxing.com/ueditorupload/read?objectId=e375aa8a412cce8cd5ff924bf0fd9a15"

其中,--content-disposition 可以让 Wget 尝试使用服务器在响应头中指定的文件名,而不是直接根据 URL 最后一段来命名。
对于这种使用 objectId 获取文件的下载接口,这个参数非常重要,否则文件可能被保存成 read?objectId=... 一类奇奇怪怪的名称。
当然,具体能否正确保留中文文件名,还要取决于服务器响应头的编码方式和 Wget 版本。

至此,问题终于有了一个相对简单的解决方向:
不再尝试让浏览器 JavaScript 伪造 Referer,而是让 Linux 用户通过 curl 或 wget 直接请求源服务器。这样不仅不需要浏览器扩展,也无需让我的网站中转压缩包。而且就算是文件体积达到几 GB,数据仍然是从下载服务器直接传输到用户本机,不会额外消耗我的 Web 服务器带宽。

不过,还有一个问题需要解决:我不可能要求所有读者都自己从一长串下载链接中提取文件 ID,再手动修改命令中的 URL 和 Referer。更何况,博客中原本已经存在大量旧版下载链接。如果逐个修改,显然很麻烦。

于是,我又想到了 PHP。

使用 PHP 根据 URL 请求获取下载链接

既然 curl 和 wget 已经可以解决下载问题,下一步就是尽可能简化用户操作,同时兼容博客文章中已经存在的旧下载链接。

我的目标很明确:让用户仍然通过浏览器点击博客中的下载链接,但打开的是一个自动生成下载命令的页面,而不是继续访问已经失效的下载接口。

原有下载链接的结构

原来的下载链接是:

https://www.nekopara.uk/share/download/?id=e375aa8a412cce8cd5ff924bf0fd9a15

仔细观察可以发现,URL 最后一段实际上是文件的唯一 ID:

e375aa8a412cce8cd5ff924bf0fd9a15

而新的可用下载接口则是:

https://mooc1.chaoxing.com/ueditorupload/read?objectId=e375aa8a412cce8cd5ff924bf0fd9a15

两者使用的文件 ID 完全一致,只是请求接口和传参方式不同,这意味着根本不需要在自己的服务器上建立文件 ID 与实际下载地址的对应数据库。

只要能够从旧链接中提取文件 ID,就能按照新的 URL 规则重新构建下载地址。

最开始,我考虑保留原来的 URL 结构,直接将域名替换成自己的下载域名:

https://www.nekopara.uk/share/download/e375aa8a412cce8cd5ff924bf0fd9a15

然后通过 Nginx 的 URL Rewrite 规则,将文件 ID 转交给 PHP 处理。

这种方案当然可行,而且链接看起来也比较简洁,但是,我的博客使用的是 Typecho,并且已经启用了伪静态路由。为了避免增加额外的 Nginx 配置,以及后续维护时出现不必要的路由冲突,我最终决定使用更直接的 PHP GET 参数形式:

https://www.nekopara.uk/share/download/index.php?id=e375aa8a412cce8cd5ff924bf0fd9a15

虽然链接变得长了一些,但它有几个明显优势:

  • 不需要为下载功能增加单独的伪静态规则。
  • 可以直接利用 PHP 的 $_GET 读取文件 ID。
  • 不需要修改 Typecho 的核心文件或路由系统。
  • 将来迁移服务器时,只需要保留 PHP 文件及相应的运行环境。
  • 调试方便,可以直接在浏览器中修改 ID 进行测试。

对于这样一个轻量级的下载辅助页面,简单、稳定显然比 URL 是否美观更加重要。

使用 PHP 提取文件 ID

PHP 处理这类请求非常容易,例如:

<?php
$id = $_GET['id'] ?? '';

if (!is_string($id) ||
    !preg_match('/^[a-fA-F0-9]{32}$/D', $id)) {
    http_response_code(400);
    exit('无效的文件 ID');
}

$url = 'https://mooc1.chaoxing.com/ueditorupload/read?objectId=' . $id;

这里首先从 GET 参数中获取文件 ID,然后通过正则表达式验证其格式。
当前下载接口使用的是 32 位十六进制 ID,因此只接受 0-9、a-f、A-F 这些字符,这个步骤一方面是为了避免输入错误,另一方面是防止用户传入任意字符串,影响后续生成的 Shell 命令。
至此,PHP 已经能够根据用户访问的 URL,自动得到实际下载地址。

使用 PHP 动态生成下载命令

下一步只需要把下载地址与 curl、wget 参数组合起来,例如:

<?php
$referer = 'https://i.chaoxing.com/';

$curl = 'curl -fL -OJ'
      . ' -e ' . escapeshellarg($referer)
      . ' ' . escapeshellarg($url);

$wget = 'wget --content-disposition'
      . ' --referer=' . escapeshellarg($referer)
      . ' ' . escapeshellarg($url);

这里使用了 PHP 自带的 escapeshellarg(),确保生成的参数能够被 Shell 正确解析。

需要说明的是:PHP 在这里并不会真正执行这些命令。

它只是将生成的命令以文本形式展示给用户,由用户自行复制到 Linux 终端运行。
也就是说,整个下载过程实际上是:

  1. 用户点击博客文章中的下载链接。
  2. 浏览器访问我自己网站上的 PHP 页面。
  3. PHP 从 URL 中提取文件 ID。
  4. PHP 根据 ID 拼接实际下载 URL,并生成 curl、wget 命令。
  5. 用户复制命令,在本地终端执行。
  6. curl 或 wget 直接连接原下载服务器,获取压缩包。

这种架构下,我的网站只负责生成一个很小的 HTML 页面,完全不参与压缩包的数据传输,也不用担心 PHP 的执行超时、内存限制,或者下载文件过大导致服务器带宽被占满的问题。

与 Typecho 共存

由于下载功能只有一个 PHP 文件,因此部署也很简单,只需要在 Typecho 网站根目录下创建:

share/
└── download/
    └── index.php

然后将下载页面代码放入 index.php,只要 Nginx 的 PHP-FPM 配置允许执行这个目录下的 PHP 文件,就可以直接通过以下地址访问:

https://www.nekopara.uk/share/download/index.php?id=文件ID

或者:

https://www.nekopara.uk/share/download/?id=文件ID

在常见的 Typecho 伪静态配置下,真实存在的 PHP 文件可以直接交给 PHP-FPM 执行,而不需要先经过 Typecho 的路由系统。
在页面设计上,我也加入了文件 ID 输入框、curl 和 wget 命令展示区,以及一键复制按钮。此外,为了确保网站风格的一致性,这个下载页面的样式参考了我网站的 NekoEcho 主题进行了配置。
这样一来,用户不需要了解 HTTP Referer,也不需要手动拼接请求地址,只需要点击下载页面中的复制按钮,再将命令粘贴到终端即可。

批量替换博客中的旧链接

PHP 文件上传了,测试无误后,最后一步是处理博客数据库中原有的下载地址,这也不难,记得备份一下数据库就好,以防万一。由于原链接和新链接都保留了相同的文件 ID,所以只需要进行一次固定字符串替换。
旧链接前缀:

https://www.nekopara.uk/share/download/?id=

新链接前缀:

https://www.nekopara.uk/share/download/index.php?id=

对于使用 MySQL/MariaDB 的 Typecho,可以在备份数据库并确认实际表名、字段名后,通过 SQL 的 REPLACE() 函数批量替换文章内容中的链接。
例如,文章表为 typecho_contents:

UPDATE typecho_contents
SET text = REPLACE(
    text,
    'https://www.nekopara.uk/share/download/?id=',
    'https://www.nekopara.uk/share/download/index.php?id='
)
WHERE text LIKE '%https://www.nekopara.uk/share/download/?id=%';

这样一来,文章中原本指向旧下载接口的链接,就会全部改成自己的下载辅助页面。如果未来实际文件服务器再次更换地址,只需要修改 PHP 中的下载接口配置,而不必再次批量修改博客数据库。

这就结束了,吗?

终于把变成了“学习堵”的学习通又变回了“学习通”。回顾整个踩坑过程,最开始我希望通过纯浏览器 JavaScript 解决下载问题,但最终发现,限制并不在于文件格式或者下载协议,而是浏览器对跨站请求头的控制。对于普通网页,任意伪造 Referer 不可行;而通过自己的服务器中转下载,又会带来额外的带宽和维护成本。相比之下,最终找到的解决方案反而非常简单:

PHP 负责解析文件 ID 和生成命令,curl/wget 负责在用户本地完成实际下载。

这套方案并没有试图突破浏览器的安全限制,而是换了一种思路:既然浏览器无法按照我的要求发送请求,那就干脆不让浏览器承担下载任务。从技术实现来看,这并不是什么复杂的方案,甚至有点朴素,但它已经满足了我最初的需求:兼容旧链接、尽量减少服务器负担、方便 Linux 用户操作,而且不需要额外安装浏览器扩展。有时候,解决问题不一定需要找到绕过限制的方法,换一种工具,反而能让整个问题简单很多。

不过,事情真的就这样结束了吗?

在整理代码、准备收尾的时候,我重新看了一遍刚刚写好的 PHP 页面,却突然发现:虽然下载过程已经不再依赖我的服务器,但这个用于生成下载命令的网页,似乎仍然没有必要依赖服务器端的 PHP。既然已经决定把实际下载任务交给 curl/wget,那么剩下的工作,还有多少是必须在服务端完成的呢?
带着这个疑问,我又开始了最后一轮折腾。

完全静态化,靠浏览器 JS

仔细检查 PHP 的代码后,我发现它实际完成的工作,无非就是:

  1. 从 URL 的 GET 参数中提取文件 ID。
  2. 检查文件 ID 是否符合格式要求。
  3. 根据文件 ID 拼接实际下载地址。
  4. 将下载地址和 Referer 组合成 curl、wget 命令。
  5. 输出 HTML 页面,供用户复制命令。

整个过程没有数据库操作,也没有复杂的服务端计算,更不涉及对源下载接口的实际请求。
换句话说,PHP 所做的事情,本质上只是一些字符串处理和页面渲染。而这些操作,浏览器 JavaScript 本来就能完成!
既然如此,为什么还要让每一次页面访问都经过 PHP-FPM 呢?直接让浏览器读取 URL 参数,在本地完成校验和命令生成,不就可以把整个下载辅助页面彻底静态化了吗?
想到这里,我决定再一次尝试浏览器 JavaScript。

不过,这次与最初的思路已经完全不同:之前是尝试让 JS 绕过浏览器的限制,直接下载文件;现在则只需要让 JS 生成下载命令,真正的文件请求仍然交给 curl/wget。这样一来,原本的 PHP 页面就可以改造成一个完全独立的 HTML 文件,不再需要 PHP-FPM,甚至不需要任何服务端动态运行环境。
于是,我开始将之前的 PHP 版本改写成纯 HTML + CSS + JavaScript 的静态页面。

将 PHP 参数解析改成 JavaScript

之前的 PHP 版本通过 $_GET 获取文件 ID:

$id = $_GET['id'] ?? '';

改成 JavaScript 后,可以直接使用浏览器原生的 URLSearchParams:

const params = new URLSearchParams(location.search);
const id = (params.get('id') || '').trim();

当用户访问:

https://www.nekopara.uk/share/download/index.html?id=e375aa8a412cce8cd5ff924bf0fd9a15

浏览器就能直接从当前页面的 URL 中提取文件 ID,随后,通过正则表达式验证 ID 格式:

const valid = /^[a-fA-F0-9]{32}$/.test(id);

由于这里的文件 ID 固定为 32 位十六进制字符串,因此不需要任何第三方库。
如果 ID 无效,就在页面上显示错误提示;如果有效,则继续生成下载命令。

在浏览器端直接生成命令

之前 PHP 使用 escapeshellarg() 对 Shell 参数进行转义,JavaScript 没有完全对应的内置函数,因此需要自己实现一个简单的 POSIX Shell 参数转义函数:

function escapeshellarg(s) {
  return "'" + String(s).replace(/'/g, "'\\''") + "'";
}

它会使用单引号包裹参数,并正确处理字符串中可能存在的单引号。然后,就可以完全在浏览器端生成下载命令:

const SOURCE_BASE = 'https://mooc1.chaoxing.com/ueditorupload/read';
const REFERER = 'https://i.chaoxing.com/';

const downloadUrl =
  SOURCE_BASE + '?objectId=' + encodeURIComponent(id);

const quotedUrl = escapeshellarg(downloadUrl);

const curlCommand =
  'curl -fL -OJ' +
  ' -e ' + escapeshellarg(REFERER) +
  ' ' + quotedUrl;

const wgetCommand =
  'wget --content-disposition' +
  ' --referer=' + escapeshellarg(REFERER) +
  ' ' + quotedUrl;

最终生成的命令与 PHP 版本一致。
不同的是,PHP 版本需要由服务器接收请求、解析参数并生成 HTML;而现在,服务器只需要返回一个静态 HTML 文件,剩下的工作全部交给用户浏览器完成。

通过 DOM API 更新页面

在之前的 PHP 版本中,下载命令是在服务端直接插入 HTML 的。静态化之后,则使用 JavaScript 修改页面内容:

document.getElementById('wget').textContent = wgetCommand;
document.getElementById('curl').textContent = curlCommand;
document.getElementById('commands').hidden = false;

这里使用 textContent,而不是 innerHTML,因为下载命令本质上只是需要展示给用户的文本,没有必要让浏览器将其解释为 HTML 标签。使用 textContent 也能降低不可信输入被当成 HTML 执行的风险。
另外,我还加入了一键复制功能:

async function copyText(id) {
  const command = document.getElementById(id).textContent;
  await navigator.clipboard.writeText(command);
}

用户点击按钮,就可以直接将生成的命令复制到剪贴板。

静态化后有什么优势?

这次修改最大的价值,其实不是减少了多少 PHP 代码,而是彻底取消了下载页面对服务端动态执行环境的依赖,最终只剩下一个 index.html 文件。
这意味着它可以部署在普通 Nginx 静态站点、Cloudflare Pages、GitHub Pages,或者任何能够托管 HTML 文件的环境中。甚至在本地直接打开 HTML 文件,只要通过页面输入框提供文件 ID,也能够生成下载命令。
相比原来的 PHP 方案,静态化带来了几个好处:

  • 部署更简单:不需要 PHP-FPM,也不需要数据库。
  • 运行开销更低:服务器只负责提供静态文件,不需要每次请求都执行 PHP。
  • 维护更容易:下载接口发生变化时,只需要修改 HTML 中的 JavaScript 配置。
  • 迁移更方便:可以直接将单个 HTML 文件复制到其他静态网站。
  • 减少攻击面:不需要额外暴露一个可执行的 PHP 接口。
  • 不受 Typecho 路由逻辑影响:只要能够正常访问这个静态文件,就可以独立使用。

无论是 PHP 版本还是静态 HTML 版本,用户实际下载压缩包时,数据都是从原下载服务器直接传输到本地,静态化只是进一步简化了命令生成页面的实现。

最终架构

经过这一轮修改,整个下载流程最终变成了:

用户点击博客中的下载链接
            ↓
访问静态 index.html?id=文件ID
            ↓
浏览器 JavaScript 解析文件 ID
            ↓
自动生成 curl / wget 命令
            ↓
用户点击复制按钮
            ↓
在 Linux 终端执行命令
            ↓
源下载服务器直接返回压缩包

很有趣的是,我最开始尝试的也是浏览器 JavaScript,而最终居然又回到了浏览器 JavaScript,还真是个轮回呢。但这两次使用 JavaScript 的目的已经完全不同:

最开始,我试图让 JavaScript 在浏览器内部完成跨域下载,并伪造 Referer;而最终,我只让 JavaScript 负责生成命令,把真正的 HTTP 下载请求交给 curl 和 wget。

前者受到浏览器安全机制的限制,后者则完全不需要突破这些限制。

这也算是一次比较有意思的技术路线变化:从纯前端下载,到命令行下载,再到 PHP 命令生成页面,最后又回到了完全静态化的前端页面。


这下学习通彻底通了。

啊?什么?你说 Windows ?还有安卓?自己想办法装 Linux 就能解决了。

参与讨论