Atheos v601 JSON API 与插件开发详解

实查确认 v601 无运维 CLI 后的接口层全景:controller.php 的 action/target 分发与恒 200 响应契约、11 类错误码实测、会话 Cookie 鉴权与缺失 CSRF/HttpOnly 的风险、会话指纹 || 缺陷实测、模块动作地图、建文件/写入/上传/下载端到端回显、路径三关与绝对路径泄露,以及插件 init.js/screen.css/controller.php/dialog.php 契约与最小可运行插件实测。

AtheosAtheos自托管PHP2026/10/27

Atheos v601 JSON API 与插件开发详解

目标:把 Atheos v601 的接口层彻底讲透——一个 controller.php 入口、action + target 两个参数、始终 200 的 HTTP 状态码藏在响应体里的 status、以及插件的真实加载契约。文中每条结论都有实测回显或源码行号支撑,实测环境:Ubuntu 26.04.1 LTS、Apache/2.4.65、PHP 8.1.34、站点根 /var/www/html、Atheos v601,日期 2026-10-27,账号 admin / q1w2e3r4。前置阅读:《Atheos v601 介绍与选型指南》、《Ubuntu 22.04 安装 Atheos v601 指南》、《Atheos v601 配置参数详解》。

一、先回答一个常被问的问题:有没有运维 CLI

动手写之前先做了实查,结论是没有:

ls /var/www/html            # 无 bin/、无 console、无 scripts/
grep -n '"bin"' composer.json    # 无匹配
grep -iE 'cli|console|command' README.md    # 无运维命令行说明

站点根目录只有 Web 入口(index.php / controller.php / dialog.php)、components/、traits/、vendor/ 等,composer.json 也没有声明可执行入口;components/install/ 是 Web 安装向导,不是迁移工具。

因此 v601 的运维动作只有三条路:

  1. Web 界面:设置、用户管理、项目管理;
  2. 直接改文件:data/users.json、data/projects.db.json(改完必须校验 JSON,见配置参数详解第六节);
  3. 调 JSON 接口:POST controller.php,这也是脚本化、批量化的唯一正道,本文主体。

二、接口形态:一个入口 + 两个参数

三个入口的分工

入口用途返回
index.php主页面骨架HTML
controller.php全部业务动作JSON
dialog.php弹窗内容片段HTML / JSON

controller.php 的完整分发逻辑只有 20 行(源码实证):

$action = POST("action");
$target = POST("target");
if ($action !== "authenticate") { Common::checkSession(); }
if (!$action || !$target) { Common::send(415, "Missing target or action."); }
if ($target === "i18n" && $action === "init") { Common::send(200, ["cache" => $i18n->getCache()]); }
if (file_exists("components/$target/controller.php")) require("components/$target/controller.php");
elseif (file_exists("plugins/$target/controller.php")) require("plugins/$target/controller.php");
else Common::send(404, "Bad target destination");

要点:

  • 先内置后插件:同名 target 时 components/ 优先,插件无法覆盖内置模块的后端接口(前端可以,见第七节)。
  • 只有 authenticate 免会话,其余动作在进入业务代码前就要求有效会话。
  • 参数一律走 POST,没有路由段、没有 REST 语义;同一个 URL 靠 action 区分动作。

响应契约:HTTP 永远 200,答案在 body 里

traits/exchange.php 第 41–53 行:

public static function send($status, $data = array()) {
    http_response_code(200);
    header("X-Frame-Options: SAMEORIGIN");
    header('X-Content-Type-Options: nosniff');
    ...
    $data["status"] = $status;

由此得到三条必须记住的行为:

  1. HTTP 状态码恒为 200,业务成败看响应体的 status;用 curl -f 或 curl --fail 判断成功会全部误判为成功。
  2. Content-Type 是 text/html; charset=UTF-8(不是 application/json),前端用 JSON.parse 而非依赖 MIME。
  3. 响应头只有 X-Frame-Options、X-Content-Type-Options 两条,config.php 里那 8 条安全头不会出现在接口响应上(详见配置篇第六节)。

错误码字典(实测 + 源码汇总)

status含义实测触发方式
200成功正常调用
403无权限 / 路径越界paths=/etc/passwd 读取
404target 不存在 / 无活动项目target=nosuch;未 load 项目时查 current
409目标已存在重复上传同名文件
415缺 action 或 target只传 target
416非法 actiontarget=filemanager&action=nosuch
417缺必填参数登录缺 password
418路径/类型非法demo/../../config.php
419缺 modifyTime(patch 模式)save 带 patch 无 modifyTime
451密码错误错误密码登录
159文件已被改动(out of sync)patch 的 modifyTime 与文件 mtime 不一致
"error"(字符串)会话失效不带 Cookie 调用

注意最后一行的 status 类型不一致:多数是整数,会话类错误是字符串 "error"。判等时用宽松比较或先转字符串,否则 {"status": 200} == 200 与 {"status": "error"} == 0 这类陷阱会让人排查半天。

三、鉴权:会话 Cookie,无 CSRF Token

登录与会话

# 1) 登录,保存 Cookie
curl -s -c jar.txt -X POST http://127.0.0.1:8130/controller.php \
  -d 'action=authenticate&target=user&username=admin&password=q1w2e3r4&language=en'
# {"username":"admin","lastLogin":"2026-10-27 19:57:13","text":"Successfully authenticated.","status":200}

# 2) 后续调用带 Cookie
curl -s -b jar.txt -X POST http://127.0.0.1:8130/controller.php -d 'action=current&target=project'
# {"name":"demo","path":"demo","status":200}

实测 Set-Cookie 只有 path=/:

Set-Cookie: <sid>=<hash>; path=/

没有 HttpOnly、没有 Secure、没有 SameSite。这意味着 Cookie 可被前端 JS 读取(XSS 场景下会话即失守),跨站 POST 的防护依赖浏览器默认策略。生产环境如果前面有反向代理,建议在代理层重写 Cookie 属性补 HttpOnly; Secure; SameSite=Lax。

会话指纹:设计存在,实测未生效

traits/checks.php 第 63–93 行给会话绑定了四项指纹:/16 网段的宽松 IP、User-Agent、Accept-Encoding、Accept-Language;任一不符就 session_destroy() 并返回 Security violation.。

但实测换 UA 仍能继续调用:

curl -s -A 'AtheosProbe/1.0' -c a4.txt -X POST .../controller.php -d 'action=authenticate&...'
curl -s -A 'AtheosProbe/2.0' -b a4.txt -X POST .../controller.php -d 'action=current&target=project'
# {"text":"No Active Projects","status":404}   ← 仍通过鉴权,未触发 Security violation

根因就在第 66–68 行:

$userAgent = SERVER("HTTP_USER_AGENT") || md5("userAgent" . $loose_ip);

|| 是逻辑或,SERVER() 有值就是 true,所以四个指纹变量实际都被压成布尔 true 再存入会话,之后比较 true !== true 恒为假——指纹校验形同虚设。这是 v601 的真实缺陷,二次开发时不要指望它做会话绑定;需要更强绑定请在 AUTH_PATH 外部认证里自行实现。修复方式(不在本文改动范围内):把四处 || 改为 ?:。

没有 CSRF Token

对 traits/、common.php、controller.php 全量搜索 csrf、xsrf、token,零匹配。所有写操作仅靠会话 Cookie 鉴权。安全建议:在反向代理层校验 Origin/Referer,或对接口路径加自定义请求头校验(同源策略下攻击者无法伪造自定义头),并把 Cookie 补上 SameSite=Lax。

四、接口地图:各模块支持的动作

实测从各 components/*/controller.php 提取的 case 分支:

target动作关键参数权限
userauthenticate changePassword create delete keepAlive logout saveActiveProject updateACLusername password languagecreate/delete 需 configure
projectcreate current delete load open renameprojectName projectPath gitRepo gitBranch写操作需 configure
filemanagercreate delete duplicate extract index loadURL move open rename savepath dest name type content patch modifyTimeread/write
transferdownload uploadpath type、$_FILES["upload"][]read/write
activeadd check list rename remove removeall setFocus—configure
settingsload save—configure
analyticsinit changeOpt update saveDuration——
updateinit saveCache—configure
codegit由 class.codegit.php 内部路由,controller.php 无 case 分支——
i18ninit(在 controller.php 里特判,不走组件)—免登录

dialog.php 走另一套:components/<target>/dialog.php 或 plugins/<target>/dialog.php,实测 filemanager 支持 create duplicate rename 三个弹窗;缺参数时返回 {"status":"error","text":"Missing target or action."}(注意这里 status 也是字符串)。

五、端到端调用实录

以「拿到项目 → 建文件 → 写入 → 读回 → 上传 → 下载」为主线,全部为实测回显(命令省略了公共前缀 http://127.0.0.1:8130/controller.php)。

1)未加载项目时查当前项目

curl -s -b jar.txt -X POST .../controller.php -d 'action=current&target=project'
# {"text":"No Active Projects","status":404}

「当前项目」不是全局状态,而是会话级的:登录后必须显式 load 一次。

2)加载项目

curl -s -b jar.txt -X POST .../controller.php -d 'action=load&target=project&projectName=demo'
# {"name":"demo","path":"demo","repo":false,"text":"demo Loaded.","lastLogin":"2026-10-27 19:57:13","status":200}

curl -s -b jar.txt -X POST .../controller.php -d 'action=current&target=project'
# {"name":"demo","path":"demo","status":200}

repo:false 表示该目录不是 Git 仓库(codegit 组件据此决定是否显示 Git 面板)。

3)列目录

curl -s -b jar.txt -X POST .../controller.php -d 'action=index&target=filemanager&path=demo'
# {"index":[{"path":"/var/www/html/workspace/demo/hello.html","link":false,"type":"file","size":23},
#           {"path":"/var/www/html/workspace/demo/notes.txt","link":false,"type":"file","size":12}],"status":200}

注意返回的是服务端绝对路径,这会把安装目录结构暴露给前端(见第六节)。

4)建文件 → 写入 → 读回

# 建空文件
curl -s -b jar.txt -X POST .../controller.php -d 'action=create&target=filemanager&path=demo/api-test.txt&type=file'
# {"modifyTime":1793102245,"status":200}

# 写入(modifyTime=0 表示不做并发校验)
curl -s -b jar.txt -X POST .../controller.php \
  --data-urlencode 'action=save' --data-urlencode 'target=filemanager' \
  --data-urlencode 'path=demo/api-test.txt' --data-urlencode 'content=hello from json api' \
  --data-urlencode 'modifyTime=0' --data-urlencode 'patch='
# {"modifyTime":1793102245,"status":200}

# 读回
curl -s -b jar.txt -X POST .../controller.php -d 'action=open&target=filemanager&path=demo/api-test.txt'
# {"content":"hello from json api","modifyTime":1793102245,"status":200}

modifyTime 是乐观锁:save 后服务端返回新 mtime,下次带 patch 时必须带上它,否则 159 out of sync。脚本化写文件时用「整段 content + modifyTime=0」最稳,避免 patch 冲突。

5)上传(multipart,字段名固定为 upload[])

curl -s -b jar.txt -F 'action=upload' -F 'target=transfer' -F 'path=demo' \
  -F 'upload[]=@/tmp/up.txt' .../controller.php
# {"filename":"up.txt","filepath":"/var/www/html/workspace/demo/up.txt","parent":".../demo","size":17,"text":"Uploaded successfully","status":200}

# 重复上传同名文件
# {"filename":"up.txt",...,"text":"File already exists.","status":409}

单文件大小上限由 PHP 的 upload_max_filesize 决定(本站 64M),超出时 status 转成上传错误码,文本取自 getUploadErrorText()。

6)下载

curl -s -b jar.txt -X POST .../controller.php -d 'action=download&target=transfer&path=demo/notes.txt&type=file'
# {"download":"/var/www/html/workspace/notes.txt","status":200}

download 不是流式响应:它把目标文件 copy() 到工作区根目录,然后返回该副本的绝对路径(源码 class.transfer.php 第 71–74 行)。两点后果:①根目录会留下同名副本,需要外部清理;②实测站点对 /workspace/... 的直接访问返回 403(.htaccess 做了限制),所以这个返回路径不能直接当下载 URL 用,前端另有下载通道。脚本里要取文件内容,用 filemanager 的 open 比 transfer download 合理。

7)鉴权失败对照

# 不带 Cookie
curl -s -X POST .../controller.php -d 'action=index&target=filemanager&path=demo'
# {"text":"Authentication error","status":"error"}

# 密码错误
curl -s -X POST .../controller.php -d 'action=authenticate&target=user&username=admin&password=wrong&language=en'
# {"text":"Invalid password.","status":451}

六、路径边界:三道路径关卡与一个信息泄露

traits/path.php 的 getWorkspacePath() 是所有文件类接口的必经关卡:

$path = Common::cleanPath($path);          // ① 清洗:去 NUL、循环删除 "../"、字符白名单、realpath 归一
if (!$path) return false;
if (!Common::checkPath($path)) Common::send(403, "Client does not have access.");  // ② 越界检查
if (Common::isAbsPath($path)) return $path;             // ③ 绝对路径:原样放行
else return WORKSPACE . "/" . $path;                     // ④ 相对路径:拼到工作区

实测三组:

请求 path结果说明
demo/../../config.php{"text":"Invalid path.","status":418}cleanPath 反复删除 ../ 后剩余路径不存在,realpath 失败
/etc/passwd{"text":"Client does not have access.","status":403}绝对路径越界被 checkPath 拦下
demo200,返回绝对路径列表工作区内正常

注意第 ③ 条:绝对路径只要落在工作区内就直接放行,这带来两个现象:filemanager index 回显 /var/www/html/workspace/demo/...(泄露安装路径),以及脚本里可以直接用绝对路径操作工作区内的文件(方便,但也意味着前端拿到的路径可直接回灌)。

cleanPath 是「删除」而不是「解析」../,遇到 demo/../../config.php 这类会变成不存在的路径而失败,方向安全但不精确;如果你的业务需要「移出到工作区外」,正确做法是改 WORKSPACE 常量,而不是构造 ../。

七、插件开发:前端合并加载 + 后端同名分发

目录契约

plugins/<name>/
├── init.js        # 前端入口(必需,否则不会被加载)
├── screen.css     # 插件样式(可选,文件名固定)
├── controller.php # 后端接口:target=<name> 时被分发(可选)
└── dialog.php     # 弹窗片段:dialog.php?target=<name>(可选)

加载逻辑在 traits/cls.source.php 第 56–63 行:扫描 PLUGINS 目录,只有同时满足文件名 init.js / screen.css 才收录;controller.php / dialog.php 由后端按需 require。

前端加载:只有登录后才注入

index.php 的三处调用:

$SourceManager->linkResource("css", "plugins", DEVELOPMENT);   // 56 行,页面顶部
if (登录态) {
    $SourceManager->linkResource("js", "components", DEVELOPMENT);  // 117 行
    $SourceManager->linkResource("js", "plugins", DEVELOPMENT);     // 120 行
}

实测差异(新增一个 plugins/apiprobe/init.js 后):

curl -s http://127.0.0.1:8130/ | grep -c 'plugins.min'      # 未登录:0
curl -s -b jar.txt http://127.0.0.1:8130/ | grep -c 'plugins.min'   # 已登录:2(css + js 各一)

这解释了一个高频困惑:「插件写好了,刷新页面没反应」——先确认自己是不是在未登录页看效果。

合并产物在 public/:plugins.min.js、plugins.min.css。linkResource() 用 mtime 判断是否需要重建(cls.source.php 第 103–116 行);实测新增插件后:

-rw-r--r-- 1 www-data www-data 747 Oct 27 19:58 /var/www/html/public/plugins.min.js   # 含 apiprobe 2 处
-rw-r--r-- 1 www-data www-data   0 Oct 27 19:53 /var/www/html/public/plugins.min.css  # 无 screen.css 时为 0 字节

DEVELOPMENT=true 时不走合并,逐文件输出,调试插件时建议临时开启(见配置篇第八节)。

后端契约:与内置模块同构

最小可用插件(本次实测用):

<?php
// plugins/apiprobe/controller.php
switch ($action) {
    case "ping":
        Common::send(200, array("plugin" => "apiprobe", "echo" => POST("msg")));
        break;
    default:
        Common::send(416, "Invalid action.");
}
curl -s -b jar.txt -X POST .../controller.php -d 'action=ping&target=apiprobe&msg=hi'
# {"plugin":"apiprobe","echo":"hi","status":200}

curl -s -b jar.txt -X POST .../dialog.php -d 'action=show&target=apiprobe'
# {"dialog":"apiprobe","action":"show","status":200}

要点与限制:

  • 插件后端无条件信任 Common::checkSession() 已完成——controller.php 在 require 插件前就校验过会话,所以插件内不用重复写鉴权;但如需按权限细分(只有 configure 能改配置),必须自己调 Common::checkAccess("configure"),否则任何登录用户都能调用。
  • target 重名时 components/ 优先,插件只能在「新名字」上扩展后端;前端则相反,插件最后加载,可以重写现有模块对象(官方 README 明确写「a module can be completely rewritten as plugins are loaded last」)。
  • 跨插件协作通过 plugins/<a>/init.js 里对全局 Atheos 对象的挂载实现,加载顺序按目录名排序,不要依赖另一个插件先加载。

前端约定:carbon 事件与 localStorage

官方 plugins/README.md 给出的三条契约(原文要点,实测环境为同一份文件):

  1. 设置自动保存:任何带 class="setting" 的输入项,会自动写入 localStorage,键名由 data-setting 指定;复杂逻辑监听 settings.dialog.save。
  2. 同步约定:配置键必须以 atheos.plugin. 前缀开头,才进入用户的同步范围;同步前后可监听 settings.save。
  3. 变更响应:监听 settings.changed:
carbon.subscribe('settings.changed', function () {
    // 配置变化后的重绘、重新请求等
});

内置模块在关键动作上发布 carbon 事件,插件订阅即可介入,不必改内置代码。这也解释了为什么「重写模块」可行:插件加载在最后,可以覆盖对象方法,而事件层保证时机。

八、排障实录

现象根因处置
脚本里 curl -f 判定接口成功,但业务其实失败Common::send() 强制 http_response_code(200),错误只在 body 的 status解析 body 的 status 字段,别信 HTTP 码;注意会话错误是字符串 "error"
接口「登录成功」后下一步全返回 Authentication error登录响应的 Set-Cookie 没被保存,或后续请求换了 UA/编码导致指纹判定(理论上)用 -c/-b 同一 Cookie 文件;先 authenticate 再调业务
插件 init.js 写了但浏览器没加载插件 JS 只在登录后的页面注入(index.php 118–120 行)登录后再看;确认文件名是 init.js
改了插件 JS 但页面仍旧public/plugins.min.js 是合并缓存,按 mtime 增量重建,同秒修改可能不触发rm public/plugins.min.js public/plugins.min.css 后刷新,或开 DEVELOPMENT=true
project current 返回 No Active Projects(404)「当前项目」存于会话,登录后未 load先 action=load&target=project&projectName=demo
上传同名文件总是失败服务端不做覆盖,status 409 File already exists.先 filemanager delete 或换名;批量导入前做存在性检查
save 带 patch 报 159 out of sync文件已被其他会话改动,mtime 与提交的 modifyTime 不一致重新 open 取最新 modifyTime 再提交,或改用整段 content 覆盖
换 UA 后会话没按预期失效,误以为「指纹绑定可用」checks.php 第 66–68 行 || 把指纹压成 true,比较恒等不要依赖该机制;需要绑定请在 AUTH_PATH 外部认证实现

九、小结

  • 接口层只有 controller.php + action/target,HTTP 恒 200、状态在 body,这是所有脚本判断的第一原则。
  • 鉴权是纯会话 Cookie,无 CSRF Token、Cookie 无 HttpOnly/SameSite,指纹校验因 || 缺陷实际失效——部署时要靠代理层补齐。
  • 文件类接口的路径关卡是 cleanPath → checkPath → isAbsPath,../ 会被删掉、绝对路径在工作区内放行(会回显绝对路径)。
  • 插件是「前端合并加载 + 后端同名分发」:init.js/screen.css 必须叫这个名,后端 controller.php 自带会话校验但不自带权限校验。
  • v601 没有运维 CLI,批量化只能走 JSON 接口或直接改 data/*.json。