FreshRSS WebSub 订阅数据目录全解析:data/PubSubHubbub/feeds目录结构与推送机制
【免费下载链接】FreshRSSA free, self-hostable news aggregator…项目地址: https://gitcode.com/gh_mirrors/fr/FreshRSS
FreshRSS 原生支持 WebSub(原名 PubSubHubbub,下称 PuSH)协议,让订阅源的新文章能够被 Hub 以「推送」方式即时送达,而不是依赖轮询拉取。本文以仓库中 data/PubSubHubbub/feeds/README.md 描述的目录结构为骨架,结合 Feed.php、p/api/pshb.php 与 config.default.php 的源码实现,深入讲解 FreshRSS 如何以文件系统为「数据库」维护 WebSub 订阅状态,包括目录布局、密钥文件、订阅/退订/续租的完整生命周期,以及推送回调的安全校验逻辑。读完本文,你将能够读懂自己实例中data/PubSubHubbub下的每一个文件,并能独立排查 WebSub 失效、租约过期、推送回调失败等常见问题。
一、feeds 目录在 WebSub 中的定位
data/PubSubHubbub/feeds/是 FreshRSS 用于登记「本实例中所有用户已订阅的 feed 的规范化 URL(canonical URL)」的目录。其 README 原文明确了两点设计意图:
- 去重:
List of canonical URLs of the various feeds users have subscribed to——多个用户可能订阅同一个 feed,目录结构需要让同一个 feed 只维护一份订阅状态; - 多用户共享:
Several users can have subscribed to the same feed——目录内以「每个用户一个标记文件」的方式记录有哪些用户共享了这次订阅。
它对应源码中的常量PSHB_PATH:
// constants.php defined('PSHB_PATH') or define('PSHB_PATH', DATA_PATH . '/PubSubHubbub');其中DATA_PATH默认是FRESHRSS_PATH . '/data'(可由环境变量DATA_PATH覆盖),因此实际路径即data/PubSubHubbub/,与 constants.php 中的定义一致。
二、feeds 目录的完整结构
README 描述的结构如下:
data/PubSubHubbub/feeds/ └── <canonicalUrl 的编码>/ # 每个 feed 一个子目录 ├── !hub.json # 该 feed 的 Hub 订阅元数据 ├── user1.txt # 订阅了该 feed 的用户标记 └── user2.txt三个要素逐一拆解:
2.1 子目录名:canonical URL 的哈希
README 中写的是base64url(canonicalUrl),但从当前仓库源码看,实际目录名使用sha1()十六进制哈希。在 Feed.php 的pubSubHubbubPrepare()中:
$path = PSHB_PATH . '/feeds/' . sha1($this->selfUrl); $hubFilename = $path . '/!hub.json';在 p/api/pshb.php 的推送回调中同样如此:
$canonicalHash = sha1($canonical); $hubFile = @file_get_contents('feeds/' . $canonicalHash . '/!hub.json');也就是说,无论 README 早期版本如何描述,当前代码以sha1(canonicalUrl)作为子目录名。使用哈希而非直接使用 URL 的原因很直观:URL 中可能包含/、?、#等不能直接作为文件路径的字符,哈希则恒定为 40 个十六进制字符,天然安全且固定长度。selfUrl即 feed 的规范化 URL,来自 feed 内容或 HTTP 头中rel="self"的链接。
2.2!hub.json:单份订阅元数据
每个 feed 子目录下的!hub.json是该 feed 的 WebSub 订阅状态机,字段由源码写入与读取:
| 字段 | 类型 | 含义 |
|---|---|---|
hub | string | 该 feed 声明的 Hub 地址(来自 feed 中的<link rel="hub">) |
key | string | 本实例为此次订阅生成的密钥,用于回调 URL 鉴权,见第三节 |
lease_start | int | 租约(lease)开始时间戳,用于防止过频繁的续租 |
lease_end | int | 租约到期时间戳(由 Hub 在订阅确认时返回的hub_lease_seconds决定) |
error | bool | 是否处于错误状态:推送验证成功前为true,首次成功推送后置为false |
创建时机在pubSubHubbubPrepare()(首次准备订阅时):
$key = sha1($path . FreshRSS_Context::systemConf()->salt); $hubJson = [ 'hub' => $this->hubUrl, 'key' => $key, ]; file_put_contents($hubFilename, json_encode($hubJson));注意key由sha1(feed目录路径 + 系统 salt)生成,salt来自安装时生成的 config.default.php 中的'salt'配置项。这使得密钥即使泄露也无法在没有 salt 的情况下反推其他 feed 的密钥。
error标志的语义在pubSubHubbubEnabled()与pubSubHubbubError()中有明确实现(Feed.php):
public function pubSubHubbubEnabled(): bool { ... if (is_array($hubJson) && empty($hubJson['error']) && (empty($hubJson['lease_end']) || $hubJson['lease_end'] > time())) { return true; } ... }即「WebSub 生效」=!hub.json存在 +error为空 + 租约未过期三个条件同时满足。任一不满足,FreshRSS 就会退回轮询拉取。
2.3 用户标记文件:user1.txt、user2.txt
目录下每个订阅用户对应一个以其用户名为文件名的空文件(内容为空,仅作标记)。它由pubSubHubbubPrepare()创建:
$currentUser = Minz_User::name() ?? ''; if (FreshRSS_user_Controller::checkUsername($currentUser) && !file_exists($path . '/' . $currentUser . '.txt')) { touch($path . '/' . $currentUser . '.txt'); }而在推送回调 p/api/pshb.php 中,FreshRSS 通过扫描glob('*.txt')得到全部订阅用户,从而回应 README 中「多用户共享同一订阅」的设计:
$users = glob('*.txt', GLOB_NOSORT); if (empty($users)) { // 没有任何用户订阅了,清理整个目录并退订 Hub $feed = new FreshRSS_Feed($canonical); $feed->pubSubHubbubSubscribe(false); unlink('!hub.json'); recursive_unlink('feeds/' . $canonicalHash); }这带来一个重要行为:多个用户订阅同一个 feed 时,FreshRSS 只向 Hub 订阅一次,Hub 推送一次内容后由本实例分发(fan-out)给所有相关用户,而不是每个用户各自订阅,有效减少 Hub 与实例间的重复流量。
三、配套的 keys 目录:订阅凭据管理
理解 feeds 目录离不开其兄弟目录 data/PubSubHubbub/keys/,README 描述其结构为:
data/PubSubHubbub/keys/ └── sha1(random + salt).txt # 文件名即订阅密钥 └── 内容:canonical URL结合源码,keys/{key}.txt的内容实际是canonical URL 的纯文本(README 中base64url(canonicalUrl)的表述在现版本中对应为直接写明文 URL)。写入发生在pubSubHubbubPrepare():
@mkdir(PSHB_PATH . '/keys/', 0770, true); file_put_contents(PSHB_PATH . '/keys/' . $key . '.txt', $this->selfUrl);它构成了回调验证的第一环:Hub 在验证订阅或推送内容时,会携带 FreshRSS 在订阅请求中提交的hub.callback,即{base_url}/api/pshb.php?k={key}。回调端 p/api/pshb.php 依次执行:
- 格式校验:
k参数必须是十六进制字符且长度不超过 128,否则返回422; - 密钥查找:读取
keys/{key}.txt得到 canonical URL,文件不存在返回410 Gone(并特别允许unsubscribe模式的宽松处理); - 交叉校验:用
sha1(canonical)定位feeds/{hash}/!hub.json,核对其中key字段与回调携带的key完全一致,防止伪造回调,不一致返回500。
$hubJson = json_decode($hubFile, true); if (!is_array($hubJson) || empty($hubJson['key']) || $hubJson['key'] !== $key) { header('HTTP/1.1 500 Internal Server Error'); die('Invalid key cross-check!'); }三层校验环环相扣,任何一个环节失败都会拒绝处理,且失败路径上会自动清理失效文件(如unlink('keys/' . $key . '.txt'))。
四、订阅、续租与退订:目录文件的生命周期
4.1 订阅(Subscribe)
订阅在 feedController.php 的 feed 添加流程中触发,逻辑分两步:
if ($pubsubhubbubEnabledGeneral && $feed->pubSubHubbubPrepare() != false) { if (!$feed->pubSubHubbubSubscribe(true)) { //Subscribe ... } }pubSubHubbubPrepare():本地落盘准备——创建feeds/{sha1}/!hub.json、keys/{key}.txt、用户标记文件;pubSubHubbubSubscribe(true):向 Hub 发送 HTTP 订阅请求,参数为(Feed.php):
hub.verify=sync hub.mode=subscribe hub.topic=<feed 的 canonical URL> hub.callback={base_url}/api/pshb.php?k={key}回调 URL 由Minz_Request::getBaseUrl() . '/api/pshb.php?k=' . $hubJson['key']拼装,因此系统配置base_url必须能被 Hub 公网访问,否则订阅无法建立。
4.2 租约(Lease)与续租
Hub 收到订阅请求后会回调 FreshRSS 的回调端点进行验证。在 p/api/pshb.php 的hub_mode=subscribe分支中:
if ($leaseSeconds > 60) { $hubJson['lease_end'] = time() + $leaseSeconds; } else { unset($hubJson['lease_end']); } $hubJson['lease_start'] = time(); if (!isset($hubJson['error'])) { $hubJson['error'] = true; //Do not assume that WebSub works until the first successful push }Hub 返回的hub_lease_seconds被记录为lease_end(小于等于 60 秒的租约视为无效,直接不设到期时间);同时error被置为true——在收到第一次成功推送之前,FreshRSS 不认为 WebSub 已经生效。
续租策略在pubSubHubbubPrepare()中(每次拉取/刷新时被调用检查):
if (!empty($hubJson['lease_end']) && $hubJson['lease_end'] < (time() + (3600 * 23))) { $key = $hubJson['key']; //To renew our lease } elseif (((!empty($hubJson['error'])) || empty($hubJson['lease_end'])) && (empty($hubJson['lease_start']) || $hubJson['lease_start'] < time() - (3600 * 23))) { $key = $hubJson['key']; //To renew our lease }即:租约将在23 小时内到期、或状态异常且距上次尝试超过 23 小时时,会复用既有key重新发起订阅以续租。源码中标注了TODO: Make a better policy,说明该阈值目前是硬编码策略。
4.3 退订(Unsubscribe)与清理
删除 feed 时,feedController.php 调用pubSubHubbubSubscribe(false),逻辑上:
- 先将
lease_end置为过去时间(time() - 60)阻止后续续租(Feed.php); - 再向 Hub 发送
hub.mode=unsubscribe请求。
Hub 侧的退订验证同样会打到回调端点,hub_mode=unsubscribe分支会核对租约状态(p/api/pshb.php):若租约已过期则直接应答hub_challenge,否则返回422(「We did not ask to unsubscribe!」)。
当最后一个用户取消订阅后,glob('*.txt')结果为空,回调会自动清理整个feeds/{sha1}/目录与keys/{key}.txt,实现无残留的自我回收。
五、一次完整的推送分发(Fan-out)流程
当发布者发布新内容,Hub 将内容 POST 到 FreshRSS 的回调端点p/api/pshb.php。除去订阅/退订验证分支,内容推送的处理链路如下(p/api/pshb.php):
- 读取并解析负载:
MAX_PAYLOAD限制为 3,145,728 字节(约 3 MB),防止超大请求拖垮实例;空负载返回422; - 提取 self 链接:用
FreshRSS_SimplePieCustom解析 XML 中的<link rel="self">,同时支持 HTTP 头Link: <...>; rel="self"(两处 URL 不一致时仅记录警告,不拒绝,见compareUrlIgnoringHttps); - 逐个用户分发:遍历
glob('*.txt')得到的用户名列表,逐个初始化用户上下文并调用:
[$nbUpdatedFeeds, ] = FreshRSS_feed_Controller::actualizeFeedsAndCommit( feed_url: $canonical, simplePiePush: $simplePie, selfUrl: $self);actualizeFeedsAndCommit是 feedController.php 中核心的 feed 实际化方法;推送模式下simplePiePush参数传入解析好的内容,直接落库而不再发起网络拉取; 4.失效用户清理:若某用户已不再订阅该 feed(返回 0 个更新),则删除其标记文件unlink($userFilename);若用户配置不存在或已禁用(!enabled),同样删除并跳过; 5.状态翻转:若至少一个用户成功更新,且!hub.json中error为true,则写回error: false,标志着「WebSub 已确认可用」; 6.结果响应:返回Done: N,日志记录WebSub <canonical> done: <nb>。
值得注意的兜底设计:在 feedController.php 中,若 feed 处于 WebSub 推送状态,却在轮询拉取时发现新文章,会触发pubSubHubbubError(true)将error置位——这意味着「推送漏了内容」,下次刷新会触发续租重新订阅,形成自愈闭环。
六、启用前置条件与配置项
WebSub 功能默认关闭,需要在安装后的data/config.php中开启,配置项定义于 config.default.php:
# Enable or not support of PubSubHubbub. # /!\ It should NOT be enabled if base_url is not reachable by an external server. 'pubsubhubbub_enabled' => false,同时必须正确设置base_url(config.default.php),注释明确指出它用于构建绝对 URL(例如 WebSub 回调):
# Specify address of the FreshRSS instance, # used when building absolute URLs, e.g. for WebSub. 'base_url' => 'https://freshrss.example.net/',在源码层面还有两个重要前置判断(Feed.php):
if ((Minz_Request::serverIsPublic($baseUrl) || self::isSameHost($this->hubUrl, $baseUrl)) && $this->hubUrl !== '' && $this->selfUrl !== '' && @is_dir(PSHB_PATH)) {即:base_url必须对公网可达(serverIsPublic),或Hub 与实例同主机(如本地开发环境 localhost 对 localhost,isSameHost的兼容场景);同时 feed 必须声明了hub链接且有self规范化 URL,且data/PubSubHubbub目录存在。Web 安装向导会在服务器看起来具有公网地址时默认启用该功能。
配套说明可见官方用户文档 docs/en/users/WebSub.md,其中补充了 WebSub 的术语(publisher / subscriber / hub)、测试工具建议以及常见支持 WebSub 的平台(WordPress、Blogger、Medium、Friendica 等)。
七、目录、日志与故障排查速查
| 路径 / 配置 | 说明 |
|---|---|
data/PubSubHubbub/feeds/{sha1(url)}/!hub.json | feed 的 WebSub 订阅元数据(hub、key、租约、错误标志) |
data/PubSubHubbub/feeds/{sha1(url)}/{username}.txt | 用户订阅标记,空文件 |
data/PubSubHubbub/keys/{key}.txt | 订阅密钥 → canonical URL 映射,回调鉴权用 |
p/api/pshb.php | WebSub 回调端点(订阅验证 + 内容推送分发) |
data/users/_/log_pshb.txt | WebSub 专用日志(常量PSHB_LOG,见 constants.php) |
'pubsubhubbub_enabled' | 总开关,默认false,见 config.default.php |
'base_url' | 实例公网地址,回调 URL 与serverIsPublic判断的依据 |
常见故障快速定位:
!hub.json不存在:feed 未声明<link rel="hub">,或base_url非公网导致pubSubHubbubPrepare()直接跳过——检查 feed 源与系统配置;error恒为true:尚未收到第一次成功推送,或出现「推送漏拉」被置位,等待下一次成功推送或续租自愈;lease_end已过期:租约未及时续期,pubSubHubbubPrepare()会在 23 小时窗口内自动续租,可观察data/users/_/log_pshb.txt中的WebSub lease ends at ...警告;- 回调返回
410 Gone:keys/{key}.txt或feeds/{hash}/!hub.json已被清理(如全部用户退订),Hub 的陈旧推送会被安全拒绝并自我清理。
结语
data/PubSubHubbub/feeds/是 FreshRSS WebSub 实现的核心状态存储:以sha1(canonical URL)组织 feed 目录,用!hub.json保存订阅元数据,用「每用户一个空文件」实现多用户共享订阅与推送分发。配合keys/目录的密钥映射和p/api/pshb.php的三层鉴权,FreshRSS 仅凭纯文件系统即完成了订阅、续租、退订、推送与自愈的完整闭环。理解这一目录结构,是运维 WebSub 实例、排查推送故障,乃至为 FreshRSS 扩展 WebSub 相关功能(如实现自定义 Hub 重定向)的第一步。
【免费下载链接】FreshRSSA free, self-hostable news aggregator…项目地址: https://gitcode.com/gh_mirrors/fr/FreshRSS
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考