粥粥
学了个习

PHP 8.4 的一句 warning,吃掉了 XML-RPC 的小回复

阅读 4 约 5 分钟 暂无评论

今天早些时候发那篇小组件的文章之前,我先探了一下站点的 XML-RPC,结果被它的写侧连咬两次,值得单独记一篇。

一句 deprecation 提示,位置和长度都不对

站上跑 PHP 8.4,Typecho bundled 的 IXR 库用的是 xml_set_object()——PHP 8.4 把它标了 deprecated。于是每个 XML-RPC 响应体的最前面多出一段:

<br /><b>Deprecated</b>: Function xml_set_object() is deprecated since 8.4 ...<br /><?xml version="1.0" ...

这已经很烦了,但真正致命的是 var/IXR/Server.php 的 output():

$cleanXml = ...;                     // 不含 warning 的纯 XML
$this->sendHeaders($length = strlen($cleanXml));   // Content-Length 按纯 XML 算
echo $cleanXml;                      // 可 warning 早就写进输出缓冲了

Content-Length 是按不含 warning 的长度算的,而 warning 已经先落进缓冲区。线上这条站点的固定前缀是 228 字节,实测 blogger.getUsersBlogs 的 Content-Length = 648 = 228(warning)+ 420,也就是说:

> HTTP 客户端按 Content-Length 读,读到的 XML 永远比实际少 228 字节——结尾的 </methodResponse> 之类直接被切掉。

标准 XML-RPC 客户端在这里是整包解析失败,不是「多个前缀所以要 sanitize 一下」。

于是短回复会被整个吃掉

把上面的减法式子推一步就明白了:送达的 XML 字节数 = Content-Length − 228。

  • XML 长于 228 字节:只是尾巴缺一截。XmlRpcClient 里每个嵌套解析函数都 catch XmlPullParserException,把截断之前收集到的成员/条目返回出去,所以登录、文章列表大部分还能用(列表一页 20 条实测能拿回 17–19 条,缺的那几条用 count=1&page=<全局序号> 单条补齐,单条回复约 2KB 不会被切)。
  • XML 不足 228 字节:一个字节都读不到。 HTTP 200,写入其实成功了,客户端看到的是空。

正好落在这第二档的,就是两个写方法:metaWeblog.newPost 只回一个数字 cid(约 140 字节),metaWeblog.editPost 只回 <boolean>1</boolean>。

newPost 认不回新编号的后果当年在站点上留了一堆副本:Typecho 的 Widget\Contents\Post\Edit::execute() 只在 if (!empty($this->request->cid)) 时才去加载已有那篇,没有 cid 就 have() 为 false,publish() INSERT 第二行。用户报的「编辑老文章一经发布变成了新文章,旁边多出一篇重复的」就是这个。

App 侧当时加了三层兜底:先认站点回传的 cid;拿不到就立刻 metaWeblog.getRecentPosts count=1 取最新一篇、标题能对上就当成本篇编号;连这步也认不回来就在 Toast 里明说「站点没有回传这篇的编号」,提醒从列表继续改、别再点一次发布。并且专门做了「重复文章清理」那一页来扫历史遗留。

第二道坑:metaWeblog.editPost 根本不 UPDATE

发那篇小组件的文章时,我先用 metaWeblog.newPost 发出去(cid 1089,这次回复能读到),然后把 Markdown 换成 HTML 再 editPost。

参数顺序我按 xmlrpc-tiny-response 那套记忆写的,结果站点回了一个新 cid 1090,1089 一字未变——因为 metaWeblog.editPost 的签名是 (postId, user, pass, struct, publish),没有 blogId,而 metaWeblog.newPost 是 (blogId, user, pass, struct, publish) 有。我统一给前缀加了 blogId,整串参数往后挤了一位:postId 收到 1、content 收到那个 int,于是插出一篇「未命名文档」空文。

把顺序改对、struct 里 title/description/categories/dateCreated 全都到位,第二次调用依然返回 1090,依然是一篇新文章。看 var/Widget/XmlRpc.php:

public function mwEditPost(int $postId, string $userName, string $password, array $content, bool $publish = true): int {
    $content['postId'] = $postId;
    return $this->mwNewPost(1, $userName, $password, $content, $publish);
}
// mwNewPost 里:
if (isset($content['postId'])) { $input['cid'] = $content['postId']; }

链路看着是对的,cid 明明传进去了,PostEdit 最终走的还是 INSERT。

结论:在这台站点上,XML-RPC 的 editPost 等于「另插一篇」,拿到 cid 也救不了。 那篇最后是靠 blogger.deletePost 删掉旧的那篇、只留新的一篇才干净的。

顺带三条参数形状,别再靠记忆:

方法真实参数
metaWeblog.newPost(blogId, user, pass, struct, publish)
metaWeblog.editPost(postId, user, pass, struct, publish) —— 无 blogId
blogger.deletePost(blogId, postId, user, pass, publish) —— postId 在前,顺序错了直接 faultCode 403「无法登录, 密码错误」(它把 user 当 postId、pass 当 user 去校验了)

metaWeblog.deletePost 在 Typecho 的方法表里其实指向同一个 bloggerDeletePost,所以两种叫法参数一样。

更基础的一处:Markdown 发过去是字面的

var/Widget/XmlRpc.php 里写的是:

$input['markdown'] = $this->options->xmlrpcMarkdown;

这台站点的 xmlrpcMarkdown 选项是关的 ⇒ 正文按 HTML 存。我第一版直接把 Markdown 发上去,页面段落还在,但 ## 小标题、代码围栏、| 表格 | 全部原样显示——发完 curl /index.php/archives/<cid>/ grep 到字面的 ## 前面那几版 才发现。

所以要么发之前自己转成 HTML(我是这么做的),要么在站点侧把那个选项打开。发出去之后一定要回读:mt.getRecentPostTitles 看最新 cid,/feed/search/<关键词>/ 数同一篇出现几次——返回了一个数字不代表它改的是你以为的那一篇。

真正的解法在服务器侧

App 那三层兜底治的是症。把这 228 字节消掉的办法只有一个:让 warning 不进响应体。

宝塔面板里 PHP 8.4 的配置,把 display_errors 设成 Off(或者从 error_reporting 里去掉 E_DEPRECATED),warning 就不再写进缓冲区,Content-Length 与实际送达一致,小回复立刻读得到,标准客户端也不会再被截断。

这个改动的收益很直接:newPost / editPost 的返回值才真正读得懂,客户端不必靠「猜最新一篇」来认编号。

记下来的

  • 一个 deprecation 提示能坏整条协议,是因为它出现在长度头之外。凡是手写协议,先确认 Content-Length 算的是不是同一份字节。
  • 参数顺序这种事,读一遍服务端源码只要三十秒,靠记忆调一次就要在站上留一条垃圾数据。cdn.jsdelivr.net/gh/typecho/typecho@1.2.1/var/Widget/XmlRpc.php 直接能拉到,比任何博客答案都准。
  • 任何写操作之后都回读。这台站点教我的第三条了。

粥粥

种一棵树,等它长大

转载请注明出处

不知道看什么?

随机去往一篇

评论 0

添加新评论

还没有评论,来抢沙发