Compare commits
6
Commits
09e202796f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
264a5e664a | ||
|
|
ed72985e7b | ||
|
|
1c244e135f | ||
|
|
f77f3d77c0 | ||
|
|
c6615faa49 | ||
|
|
602917698a |
@@ -8,8 +8,31 @@
|
|||||||
- GET /api/runtime-session.js:无需登录,返回 JavaScript、Cache-Control: no-store,用于运行时页面续期。
|
- GET /api/runtime-session.js:无需登录,返回 JavaScript、Cache-Control: no-store,用于运行时页面续期。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
- UPLOAD_ALLOWED_EXTENSIONS:可选逗号分隔扩展名白名单(例如 .txt,.md);空表示不限制。大小写不敏感,非内容/恶意软件检测。
|
- UPLOAD_ALLOWED_EXTENSIONS:可选逗号分隔扩展名白名单(例如 .txt,.md);空表示不限制。大小写不敏感,非内容/恶意软件检测。
|
||||||
|
|
||||||
|
## 实例备份与快照管理
|
||||||
|
|
||||||
|
### 完整 home 快照恢复
|
||||||
|
|
||||||
|
`GET /api/instances/:id/snapshots` 的每项另含 `restorable:boolean`、`restore_reason:string`、可选 `source_version` 与 `source_image`。来源记录在独立的 `snapshot_metadata` 表中,不会从当前实例推断旧快照版本。已知镜像或版本不一致时不可恢复;来源未知时 `restorable:true` 但 `restore_reason` 为 `snapshot source image/version unknown; explicit acknowledgement required`,客户端必须显示风险警告并要求额外确认。保护状态及大小读取错误时不可恢复。列表只是预检提示,最终恢复还会检查实时配额、容器布局、镜像准入与读者占用。
|
||||||
|
|
||||||
|
`POST /api/instances/:id/snapshots` 接收 `{id,password,confirm:true,allow_unknown_version?:true}`(JSON 最大4096字节,在获取锁前读取)。`password` 是当前登录密码,不是旧备份密码;需正常登录、同源 Origin 与 CSRF。来源未知必须明确传入 `allow_unknown_version:true`;此开关不能绕过已知不兼容。成功返回 `200 {ok:true,snapshot_id:"pre-restore-<32hex>"}`,源快照保持不变。
|
||||||
|
|
||||||
|
恢复替换整个 home,不是仅覆盖配置。仅支持经核验的模板7独立 /workspace bind 挂载与当前实例镜像;拒绝旧共享布局、被阻止镜像、恢复保护状态及活跃文件读者。先进行配额和保留数量检查,再使用 rooted 路径验证复制到同文件系统暂存目录;外部/绝对/悬空符号链接及特殊文件拒绝。停止运行时后重新检查配额,将当前 home 重命名保留为 pre-restore 快照,再安装暂存目录并验证就绪;原来停止的实例会临时启动验证后再次停止。失败时独立超时上下文执行回滚,失败的新 home 保留为 failed-restore 快照;无法安全回滚时保持 error,要求管理员恢复。
|
||||||
|
|
||||||
|
非法请求或不安全内容400,当前密码错误403,目标不存在/所有权不符404,保护/读者占用/不兼容/缺少未知版本确认/保留上限409,请求或配额超限413,存储/数据库/回滚故障500,容器检查/停止失败502。正常恢复前会记录 snapshot.restore.started,完成或回滚分别记录 snapshot.restore.completed / snapshot.restore.rolled_back。此接口不改变实例镜像。
|
||||||
|
|
||||||
|
- `DELETE /api/instances/:id/backups?id=<32hex>` 删除当前用户实例的加密 `.dshbak` 包及其索引,记录 `backup.delete` 审计。ID 为32位小写十六进制;支持无索引的旧包,以及文件已不存在的残留索引。
|
||||||
|
- `GET /api/instances/:id/snapshots` 返回数组 `{id,size,created_at,protected,protection_reason,size_error?}`。`size` 为普通文件逻辑字节总和(与配额口径相同,硬链接逐路径计数,不跟随符号链接);扫描失败为 `-1` 并提供 `size_error`,不能显示为零。`created_at` 为目录修改时间的 RFC3339 字符串,不保证真实创建时间。未保护时 `protection_reason` 是空字符串。
|
||||||
|
- `DELETE /api/instances/:id/snapshots?id=<id>` 删除选定快照。先持久化 `snapshot.delete.started`,删除成功后记录 `snapshot.delete.completed`,删除失败则记录 `snapshot.delete.failed`;target 为实例 ID,detail 为快照 ID。初始审计失败时不会删除,终态审计失败返回500并保留 started 记录。快照另支持 GET 下载,见下方接口清单及 OPERATIONS.md 的归档限制。
|
||||||
|
|
||||||
|
仅识别实例目录直属的 `pre-restore-<32hex>`、`failed-restore-<32hex>`、`upgrade-<32hex>-snapshot`、`failed-home-<32hex>` 真实目录;不列出或删除 `home`、其它命名或符号链接目录。删除成功返回 `200 {ok:true}`,非法ID/不安全路径400,不存在/所有权不符404,恢复保护409,存储/索引/审计错误500。不支持的方法405。删除仍要求同源 Origin 与 CSRF,不要求备份密码,不调用 Docker inspect/stop/restart。
|
||||||
|
|
||||||
|
实例 `error`、未完成升级(仅 `complete` / `rolled_back` 为终态)、恢复日志查询错误时拒绝两类删除。快照列表仍可读取并报告保护原因。所有权在实例锁与用户锁内重新检查。无自动清理;文件系统和数据库无法原子提交,包删除后的索引提交失败会返回500,重试同一删除可清理残留索引。
|
||||||
|
|
||||||
|
快照递归删除期间保留实例锁与用户锁,但不持有 SQL 连接或事务。仅明确识别的非法/不安全路径返回400;权限、I/O 等操作系统故障返回500。快照目录不存在时列表返回空数组,删除返回404。
|
||||||
|
|
||||||
## 管理员孤儿回收
|
## 管理员孤儿回收
|
||||||
|
|
||||||
GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id=<id>&confirm=<相同完整id> 二次核验后非强制删除,保留bind数据及网络。仅接受本项目身份/挂载匹配、已停止、无实例或升级引用的容器;普通用户403,确认不匹配400,状态不安全409。
|
GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id=<id>&confirm=<相同完整id> 二次核验后非强制删除,保留bind数据及网络。仅接受本项目身份/挂载匹配、已停止、无实例或升级引用的容器;普通用户403,确认不匹配400,状态不安全409。
|
||||||
@@ -128,6 +151,11 @@ GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id
|
|||||||
- POST /api/instances/{id}/backups multipart file,password(导入外部包,password为原包密码;不立即覆盖home)
|
- POST /api/instances/{id}/backups multipart file,password(导入外部包,password为原包密码;不立即覆盖home)
|
||||||
- GET /api/instances/{id}/backups
|
- GET /api/instances/{id}/backups
|
||||||
- GET /api/instances/{id}/backups?download={backup_id}
|
- GET /api/instances/{id}/backups?download={backup_id}
|
||||||
|
- DELETE /api/instances/{id}/backups?id={backup_id} → {ok:true}
|
||||||
|
- GET /api/instances/{id}/snapshots → [{id,created_at,size,protected,protection_reason,restorable,restore_reason,source_version?,source_image?,size_error?}]
|
||||||
|
- DELETE /api/instances/{id}/snapshots?id={snapshot_id} → {ok:true}
|
||||||
|
- GET /api/instances/{id}/snapshots?download={snapshot_id} → 完整 home 的 `.tar.gz` 附件(未加密,可能含凭据)
|
||||||
|
- POST /api/instances/{id}/snapshots {id,password,confirm:true,allow_unknown_version?} → {ok:true,snapshot_id}(当前登录密码;snapshot_id 为切换前 home 的保留快照)
|
||||||
- POST /api/instances/{id}/restore {backup_id,password}(原备份密码;旧平台key包可留空)
|
- POST /api/instances/{id}/restore {backup_id,password}(原备份密码;旧平台key包可留空)
|
||||||
- POST /api/instances/{id}/upgrade {image_id,password}(当前登录密码,加密pre-upgrade备份)
|
- POST /api/instances/{id}/upgrade {image_id,password}(当前登录密码,加密pre-upgrade备份)
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -49,7 +49,9 @@
|
|||||||
|
|
||||||
升级到本版本后应在维护窗口逐个访问现有实例,完成首次认证持久化。若在首次成功连接前,启动token已被Docker日志轮转删除,本版本无法凭空恢复;先保存工作,再由管理员安排重启相关实例并访问验证。不要为此删除或重建实例。初次日志扫描上限64 MiB,常规每文件10 MiB/3份保留在此范围内。
|
升级到本版本后应在维护窗口逐个访问现有实例,完成首次认证持久化。若在首次成功连接前,启动token已被Docker日志轮转删除,本版本无法凭空恢复;先保存工作,再由管理员安排重启相关实例并访问验证。不要为此删除或重建实例。初次日志扫描上限64 MiB,常规每文件10 MiB/3份保留在此范围内。
|
||||||
|
|
||||||
- 实例升级选择已经启动验证且准入的本地模板7镜像并提供当前登录密码,面板自动停机、创建密码配置备份与home快照、启动验证及失败回滚,无需先手动停止。home快照最大1 GiB;允许受约束的内部相对链接以支持pnpm,拒绝不安全链接/特殊文件。模板7的 `/workspace` 是实例home,包含在home回滚内;旧共享挂载布局会在升级前被拒绝。跨DSH版本历史验证范围见 ACCEPTANCE.md。
|
- 实例升级选择已经启动验证且准入的本地模板7镜像并提供当前登录密码,面板自动停机、创建密码配置备份与home快照、启动验证及失败回滚,无需先手动停止。home快照最大1 GiB;软链接按链接本身保存,保留相对、绝对、失效及循环链接的目标文本,不读取或备份链接指向的外部内容;仍拒绝 socket、FIFO 等特殊文件。模板7的 `/workspace` 是实例home,包含在home回滚内;旧共享挂载布局会在升级前被拒绝。跨DSH版本历史验证范围见 ACCEPTANCE.md。
|
||||||
|
- 升级快照先复制到 `.upgrade-<ID>-snapshot-partial`,完成后才发布正式目录并记录完成状态。复制失败会清理临时目录;清理失败会报告并记录 `upgrade.snapshot.cleanup_failed`,管理员核实后处理。进程中断也可能留下临时目录,不会作为可恢复快照列出。已发布但未记录完成的快照会显示不可恢复。旧版残留没有可靠的完成记录,本版本不会自动判定其完整或自动删除,建议保留证据并重新生成快照。
|
||||||
|
- 完整快照恢复和下载同样保留软链接原文;失效链接不会自动修复,外部目标不属于恢复范围。下载的 tar.gz 可能包含绝对或目录外链接,应在隔离目录使用安全解压工具,不要跟随链接写入宿主机。独立的加密配置备份仍只接受白名单普通文件,配置路径和资源中的软链接仍会被拒绝。
|
||||||
- Profile是例外:当前需先停止实例,再使用“保存并启动”;页面包含Monaco和脱敏配置预览。不要把备份/升级自动停启理解为Profile可在线改写。
|
- Profile是例外:当前需先停止实例,再使用“保存并启动”;页面包含Monaco和脱敏配置预览。不要把备份/升级自动停启理解为Profile可在线改写。
|
||||||
- 若实例进入 `error`,不要直接修改数据库状态强制启动。保留原容器、实例目录与 `upgrade_journal` 记录,检查实际容器与 journal 的旧/新容器 ID、阶段、快照路径是否一致。
|
- 若实例进入 `error`,不要直接修改数据库状态强制启动。保留原容器、实例目录与 `upgrade_journal` 记录,检查实际容器与 journal 的旧/新容器 ID、阶段、快照路径是否一致。
|
||||||
- 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。
|
- 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。
|
||||||
@@ -118,6 +120,36 @@ bash scripts/check-trial.sh
|
|||||||
|
|
||||||
`scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。运行前需由管理员通过 Git 发布构建准备已验证且准入的本地 runtime 镜像;可用 `TEST_FROM_VERSION` / `TEST_TO_VERSION` 选择源和目标版本。脚本不提交构建,只复用这些镜像,会创建及删除测试实例,删除时一并清理实例数据。不要对生产账号运行。`scripts/smoke-image.sh <image-id>` 和 `scripts/test-mobile-image.mjs <image-id>` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。
|
`scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。运行前需由管理员通过 Git 发布构建准备已验证且准入的本地 runtime 镜像;可用 `TEST_FROM_VERSION` / `TEST_TO_VERSION` 选择源和目标版本。脚本不提交构建,只复用这些镜像,会创建及删除测试实例,删除时一并清理实例数据。不要对生产账号运行。`scripts/smoke-image.sh <image-id>` 和 `scripts/test-mobile-image.mjs <image-id>` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。
|
||||||
|
|
||||||
|
## Office 文档预览超时
|
||||||
|
|
||||||
|
Office 文档预览的运行时接口 `/api/officeToPdf/render`(面板路径为 `/dsh/:id/api/officeToPdf/render`)使用独立代理连接池,响应头等待上限为120秒;其它运行时请求及认证仍保持原有20秒上限。此设置避免转换尚未完成就被面板提前截断为502,不修改DSH转换器自身的超时(当前镜像默认60秒),也不保证转换一定成功。浏览器取消请求仍会传递至上游。若面板前还有Nginx等代理,其等待上限也应允许转换完成。此修复只需更新面板,无需为此重建运行镜像。
|
||||||
|
|
||||||
|
## 手动备份空间管理
|
||||||
|
|
||||||
|
实例备份包和历史完整工作区快照必须由用户显式删除,不执行自动清理。快照列表与删除接口见 API.md。删除快照会降低工作区逻辑用量;加密包在独立 backups 目录,不计入当前工作区软配额。快照大小不等于物理磁盘占用,硬链接按路径重复计数;`size_error` 表示无法完整扫描,应显示未知而非零。
|
||||||
|
|
||||||
|
恢复失败、未完成升级及恢复日志不可读时,列表仍报告保护状态,但禁止删除包和快照。先完成恢复并核实运行状态和升级日志,不要为了释放空间直接修改日志状态。`home` 永远不属于历史快照管理对象。删除不需要停机或 Docker 可用,也不会触发重启。
|
||||||
|
|
||||||
|
文件删除与 SQLite 提交不具备跨系统原子性。若加密包已删除而索引提交失败,接口明确返回500;重试相同删除会清理残留索引。快照删除先持久化 `snapshot.delete.started`,失败则不触碰目录;递归删除期间不占用 SQL 连接或事务,仍保留实例锁和用户锁。随后分别记录 `snapshot.delete.completed` 或 `snapshot.delete.failed`。终态审计写入失败返回500;只有 started 的记录也可能表示进程中断或操作仍在进行,不代表删除成功,应由管理员核实目录和审计记录。目录删除中途遇到存储错误可能部分完成,排除故障后再重试。明确不安全路径返回400,权限或 I/O 故障返回500,缺失快照删除返回404(列表为空)。
|
||||||
|
|
||||||
|
## 完整快照下载与手动恢复
|
||||||
|
|
||||||
|
实例的备份面板提供完整快照下载和恢复入口。下载的是未加密 `.tar.gz`,不同于仅包含配置资源的加密 `.dshbak`;可能包含密钥、会话和依赖,请妥善保管。归档保留权限及安全的相对符号链接,不跟随链接读取外部文件,遇到特殊文件或逃逸链接整体拒绝。下载先在系统临时目录完成归档,再释放实例锁传输;临时文件在请求结束时清理,需预留临时磁盘空间。文件内容总量上限1 GiB、100000条目、128层,另有限额容纳归档头及压缩开销;归档和传输分别有两分钟时限,共用最多4个目录归档下载槽。
|
||||||
|
|
||||||
|
恢复针对服务器保留的快照,不支持上传任意 tar 包恢复。需当前登录密码和明确确认;源快照保留不动,先复制到暂存目录,再停机并保留当前 home,切换后启动检查,失败尝试回退。原先停止的实例检查后仍停止。恢复需要额外容纳一份所选快照的配额和磁盘空间;已有恢复快照达到3个时先清理。已知源镜像或版本与当前实例不一致时禁止恢复;历史快照缺少来源信息时须额外确认风险。新升级、配置恢复和完整快照恢复会记录来源信息。
|
||||||
|
|
||||||
|
error 状态、未完成升级、活跃文件操作、禁用镜像或不兼容挂载布局会阻止恢复。恢复中断或回退失败保留 error 状态及数据,需管理员结合 `snapshot.restore.*` 审计核查 home、pre-restore、failed-restore 和暂存目录;不要直接把状态改成 running。快照只恢复 home,不回滚镜像、外部服务或启动产生的外部副作用。
|
||||||
|
|
||||||
|
### 完整快照恢复安全说明
|
||||||
|
|
||||||
|
快照恢复会替换整个实例 home,必须由所有者重新输入当前登录密码并明确确认。仅恢复到相同的已知镜像与版本;历史快照没有可靠来源记录时必须显示未知版本风险并额外确认,不得把当前实例版本当作历史来源。旧升级日志的 image_id 是目标镜像,不是旧 home 的可靠来源。新快照的来源写入 SQLite snapshot_metadata 表,升级回滚留下的 failed-home 使用尝试升级的目标镜像来源。
|
||||||
|
|
||||||
|
源快照不会被消耗。操作将源复制到实例目录下的 .snapshot-restore-<32hex>,确认 Docker 已停止后,把原 home 重命名为 pre-restore-<32hex>,再以同文件系统重命名安装副本并 fsync 父目录。运行时启动并通过就绪验证后恢复原运行/停止状态。原来停止的实例也会临时启动验证,因此应把恢复视为可执行该快照内容的操作,仅恢复可信数据。
|
||||||
|
|
||||||
|
开始停止/切换前实例状态持久化为 error;中途进程退出不会自动清除此状态。错误恢复时先停止容器,核对 snapshot.restore.started 审计中的源、pre-restore、failed-restore ID,以及 home 和暂存目录。不要盲目删除或覆盖这些目录;先确认哪一个保存原 home,保留全部恢复证据与源快照。只有核对目录、挂载和运行时就绪后才解除恢复状态。无法停止、重命名、同步或回滚就绪时接口保持 error 并要求人工处理。
|
||||||
|
|
||||||
|
恢复前按源副本大小检查用户配额,停止运行时后再次核验总用量;原 home 通过重命名保留,不重复复制。已有 pre-restore / failed-restore 数量达到3时拒绝,需显式清理不再需要的快照。没有自动保留清理;进程异常退出的隐藏暂存副本须在维护窗口核验后清理。文件复制期间不持有数据库事务。此流程仍需部署环境提供同文件系统原子重命名及 fsync 持久性,不能替代异机备份。
|
||||||
|
|
||||||
## 会话有效期
|
## 会话有效期
|
||||||
|
|
||||||
在 `.env` 设置 `SESSION_TTL=24h`(默认),七天可设置为 `168h`。使用 Go duration 单位 `h`、`m`、`s`,不支持 `d`;必须至少一秒且为整秒,非法值会阻止启动。修改后运行 `docker compose up -d --force-recreate panel`。新登录使用新期限;已有会话保留原绝对到期时间,刷新不会延长。access token签发有效期为五分钟,但数据库会话过期或被撤销后,即使token尚未到期也不能认证。修改自己的密码或管理员重设密码会撤销该用户所有会话,需要重新登录。`.env` 由 Compose 读取,直接运行二进制时应在进程环境中导出 `SESSION_TTL`。
|
在 `.env` 设置 `SESSION_TTL=24h`(默认),七天可设置为 `168h`。使用 Go duration 单位 `h`、`m`、`s`,不支持 `d`;必须至少一秒且为整秒,非法值会阻止启动。修改后运行 `docker compose up -d --force-recreate panel`。新登录使用新期限;已有会话保留原绝对到期时间,刷新不会延长。access token签发有效期为五分钟,但数据库会话过期或被撤销后,即使token尚未到期也不能认证。修改自己的密码或管理员重设密码会撤销该用户所有会话,需要重新登录。`.env` 由 Compose 读取,直接运行二进制时应在进程环境中导出 `SESSION_TTL`。
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var snapshotIDPattern = regexp.MustCompile(`^(pre-restore-[a-f0-9]{32}|failed-restore-[a-f0-9]{32}|upgrade-[a-f0-9]{32}-snapshot|failed-home-[a-f0-9]{32})$`)
|
||||||
|
|
||||||
|
var errUnsafeManagementPath = errors.New("unsafe storage path")
|
||||||
|
|
||||||
|
func managementPathError(w http.ResponseWriter, err error) {
|
||||||
|
if errors.Is(err, errUnsafeManagementPath) {
|
||||||
|
fail(w, 400, "unsafe storage path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(w, 500, "storage unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshotInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
ProtectionReason string `json:"protection_reason"`
|
||||||
|
SizeError string `json:"size_error,omitempty"`
|
||||||
|
Restorable bool `json:"restorable"`
|
||||||
|
RestoreReason string `json:"restore_reason"`
|
||||||
|
SourceVersion string `json:"source_version,omitempty"`
|
||||||
|
SourceImage string `json:"source_image,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func managementRoot(base string, parts ...string) (*os.Root, error) {
|
||||||
|
root, err := os.OpenRoot(base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "/\\") {
|
||||||
|
root.Close()
|
||||||
|
return nil, errUnsafeManagementPath
|
||||||
|
}
|
||||||
|
st, err := root.Lstat(part)
|
||||||
|
if err != nil {
|
||||||
|
root.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !st.IsDir() {
|
||||||
|
root.Close()
|
||||||
|
return nil, errUnsafeManagementPath
|
||||||
|
}
|
||||||
|
next, err := root.OpenRoot(part)
|
||||||
|
root.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
actual, err := next.Stat(".")
|
||||||
|
if err != nil {
|
||||||
|
next.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !os.SameFile(st, actual) {
|
||||||
|
next.Close()
|
||||||
|
return nil, errUnsafeManagementPath
|
||||||
|
}
|
||||||
|
root = next
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) backupProtection(i store.Instance) string {
|
||||||
|
if i.Status == "error" {
|
||||||
|
return "instance recovery required"
|
||||||
|
}
|
||||||
|
var exists, pending int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&exists); err != nil {
|
||||||
|
return "recovery journal unavailable"
|
||||||
|
}
|
||||||
|
if exists != 0 {
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM upgrade_journal WHERE instance_id=? AND (phase IS NULL OR phase NOT IN ('complete','rolled_back'))", i.ID).Scan(&pending); err != nil {
|
||||||
|
return "recovery journal unavailable"
|
||||||
|
}
|
||||||
|
if pending != 0 {
|
||||||
|
return "instance recovery required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotBytes(ctx context.Context, root *os.Root, name string) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
count := 0
|
||||||
|
err := fs.WalkDir(root.FS(), name, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if count > 100000 || strings.Count(p, "/") > 128 {
|
||||||
|
return errors.New("tree limit")
|
||||||
|
}
|
||||||
|
st, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if st.Mode().IsRegular() {
|
||||||
|
total += st.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) backupManagement(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
|
||||||
|
w, flush := deferJSONResponse(w)
|
||||||
|
defer flush()
|
||||||
|
if r.Method != http.MethodDelete && !(action == "snapshots" && r.Method == http.MethodGet) {
|
||||||
|
fail(w, 405, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.URL.Query().Get("id")
|
||||||
|
if r.Method == http.MethodDelete && ((action == "snapshots" && !snapshotIDPattern.MatchString(id)) || (action == "backups" && !backupIDPattern.MatchString(id))) {
|
||||||
|
fail(w, 400, "invalid ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lock := a.instanceLocks.Acquire(i.ID)
|
||||||
|
defer lock.Release()
|
||||||
|
user := a.userLocks.Acquire(i.UID)
|
||||||
|
defer user.Release()
|
||||||
|
current, err := a.s.GetInstance(i.ID)
|
||||||
|
if err != nil || current.UID != i.UID {
|
||||||
|
fail(w, 404, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i = current
|
||||||
|
reason := a.backupProtection(i)
|
||||||
|
if r.Method == http.MethodDelete && reason != "" {
|
||||||
|
fail(w, 409, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := []string{"workspaces", i.UID, "instances", i.ID}
|
||||||
|
if action == "backups" {
|
||||||
|
parts = []string{"backups", i.UID, i.ID}
|
||||||
|
}
|
||||||
|
root, err := managementRoot(a.root, parts...)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
if action == "backups" {
|
||||||
|
a.deleteBackup(w, i, nil, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
writeJSON(w, []snapshotInfo{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(w, 404, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
managementPathError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
f, err := root.Open(".")
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "snapshot listing failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := f.ReadDir(-1)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "snapshot listing failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := []snapshotInfo{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !snapshotIDPattern.MatchString(entry.Name()) || !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "snapshot metadata unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := snapshotInfo{ID: entry.Name(), CreatedAt: st.ModTime(), Protected: reason != "", ProtectionReason: reason}
|
||||||
|
s.SourceVersion, s.SourceImage, s.RestoreReason = a.snapshotCompatibility(i, s.ID)
|
||||||
|
s.Restorable = s.RestoreReason == "" || s.RestoreReason == snapshotUnknownReason
|
||||||
|
if reason != "" {
|
||||||
|
s.Restorable = false
|
||||||
|
s.RestoreReason = reason
|
||||||
|
}
|
||||||
|
s.Size, err = snapshotBytes(r.Context(), root, entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
s.Size = -1
|
||||||
|
s.SizeError = "snapshot size unavailable: " + err.Error()
|
||||||
|
s.Restorable = false
|
||||||
|
s.RestoreReason = "snapshot size unavailable"
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
writeJSON(w, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if action == "backups" {
|
||||||
|
a.deleteBackup(w, i, root, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := root.Lstat(id)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
fail(w, 404, "snapshot not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
managementPathError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !st.IsDir() {
|
||||||
|
fail(w, 400, "unsafe snapshot path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.deleteSnapshot(w, i, id, func() error { return root.RemoveAll(id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) deleteSnapshot(w http.ResponseWriter, i store.Instance, id string, remove func() error) {
|
||||||
|
audit := func(state string) error {
|
||||||
|
_, err := a.s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "snapshot.delete."+state, i.ID, id, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := audit("started"); err != nil {
|
||||||
|
fail(w, 500, "audit unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := remove(); err != nil {
|
||||||
|
if err := audit("failed"); err != nil {
|
||||||
|
fail(w, 500, "snapshot deletion failed; audit unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(w, 500, "snapshot deletion failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := audit("completed"); err != nil {
|
||||||
|
fail(w, 500, "snapshot removed; completion audit unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) deleteBackup(w http.ResponseWriter, i store.Instance, root *os.Root, id string) {
|
||||||
|
name := id + ".dshbak"
|
||||||
|
var st os.FileInfo
|
||||||
|
statErr := error(os.ErrNotExist)
|
||||||
|
if root != nil {
|
||||||
|
st, statErr = root.Lstat(name)
|
||||||
|
}
|
||||||
|
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
fail(w, 500, "backup unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if statErr == nil && !st.Mode().IsRegular() {
|
||||||
|
fail(w, 400, "unsafe backup path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx, err := a.s.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "backup index unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var exists, indexed int
|
||||||
|
err = tx.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='backups'").Scan(&exists)
|
||||||
|
if err == nil && exists != 0 {
|
||||||
|
var result sql.Result
|
||||||
|
result, err = tx.Exec("DELETE FROM backups WHERE id=? AND uid=? AND instance_id=?", id, i.UID, i.ID)
|
||||||
|
if err == nil {
|
||||||
|
n, e := result.RowsAffected()
|
||||||
|
err = e
|
||||||
|
indexed = int(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "backup index unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if statErr != nil && indexed == 0 {
|
||||||
|
fail(w, 404, "backup not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "backup.delete", i.ID, id, time.Now().Unix()); err != nil {
|
||||||
|
fail(w, 500, "audit unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if statErr == nil {
|
||||||
|
if err = root.Remove(name); err != nil {
|
||||||
|
fail(w, 500, "backup deletion failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filesystem and SQLite cannot commit atomically; a retry clears a stale index after a failed commit.
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
fail(w, 500, "backup removed; index update failed, retry deletion")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSnapshotManagement(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
a.docker = nil // These operations must never inspect or stop Docker.
|
||||||
|
parent := filepath.Dir(home)
|
||||||
|
suffix := strings.Repeat("a", 32)
|
||||||
|
names := []string{"pre-restore-" + suffix, "failed-restore-" + suffix, "upgrade-" + suffix + "-snapshot", "failed-home-" + suffix}
|
||||||
|
for _, name := range append(append([]string{}, names...), "pre-restore-no", "upgrade-"+suffix, "other") {
|
||||||
|
if err := os.Mkdir(filepath.Join(parent, name), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configWrite(t, filepath.Join(parent, name, "data"), "abc")
|
||||||
|
}
|
||||||
|
outside := t.TempDir()
|
||||||
|
configWrite(t, filepath.Join(outside, "keep"), "outside")
|
||||||
|
if err := os.Symlink(outside, filepath.Join(parent, names[0], "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Link(filepath.Join(parent, names[0], "data"), filepath.Join(parent, names[0], "hardlink")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "GET", "snapshots", "")
|
||||||
|
var got []snapshotInfo
|
||||||
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 4 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
for _, s := range got {
|
||||||
|
want := int64(3)
|
||||||
|
if s.ID == names[0] {
|
||||||
|
want = 6
|
||||||
|
}
|
||||||
|
if s.Size != want || s.Protected || s.ProtectionReason != "" || s.SizeError != "" || s.CreatedAt.IsZero() {
|
||||||
|
t.Fatalf("%+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
w = configCall(a, i, "DELETE", "snapshots?id="+name, "")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(parent, name)); !os.IsNotExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{home, filepath.Join(parent, "other"), filepath.Join(outside, "keep")} {
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='snapshot.delete.completed' AND uid=? AND target=?", i.UID, i.ID).Scan(&count); err != nil || count != 4 {
|
||||||
|
t.Fatal(count, err)
|
||||||
|
}
|
||||||
|
if w = configCall(a, i, "DELETE", "snapshots?id="+names[0], ""); w.Code != 404 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementInvalidPaths(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
a.docker = nil
|
||||||
|
for _, action := range []string{"snapshots", "backups"} {
|
||||||
|
for _, id := range []string{"", "home", "..", "../home", "/home", strings.Repeat("A", 32), "pre-restore-" + strings.Repeat("a", 32) + "/../home"} {
|
||||||
|
if w := configCall(a, i, "DELETE", action+"?id="+url.QueryEscape(id), ""); w.Code != 400 {
|
||||||
|
t.Fatal(action, id, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id := "failed-home-" + strings.Repeat("b", 32)
|
||||||
|
outside := t.TempDir()
|
||||||
|
configWrite(t, filepath.Join(outside, "keep"), "safe")
|
||||||
|
if err := os.Symlink(outside, filepath.Join(filepath.Dir(home), id)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w := configCall(a, i, "DELETE", "snapshots?id="+id, ""); w.Code != 400 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if err := os.Rename(filepath.Dir(home), filepath.Dir(home)+"-saved"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, filepath.Dir(home)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, method := range []string{"GET", "DELETE"} {
|
||||||
|
if w := configCall(a, i, method, "snapshots?id="+id, ""); w.Code != 400 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(outside, "keep")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementProtection(t *testing.T) {
|
||||||
|
for _, state := range []string{"error", "pending", "journal-error", "complete", "rolled_back"} {
|
||||||
|
t.Run(state, func(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
id := "pre-restore-" + strings.Repeat("c", 32)
|
||||||
|
if err := os.Mkdir(filepath.Join(filepath.Dir(home), id), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if state == "error" {
|
||||||
|
_, err = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID)
|
||||||
|
} else if state == "journal-error" {
|
||||||
|
_, err = a.s.DB.Exec("CREATE TABLE upgrade_journal(broken TEXT)")
|
||||||
|
} else {
|
||||||
|
_, err = a.s.DB.Exec("CREATE TABLE upgrade_journal(instance_id TEXT,phase TEXT)")
|
||||||
|
if err == nil {
|
||||||
|
_, err = a.s.DB.Exec("INSERT INTO upgrade_journal VALUES(?,?)", i.ID, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
protected := state != "complete" && state != "rolled_back"
|
||||||
|
w := configCall(a, i, "GET", "snapshots", "")
|
||||||
|
var got []snapshotInfo
|
||||||
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 1 || got[0].Protected != protected || (got[0].ProtectionReason != "") != protected {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = configCall(a, i, "DELETE", "snapshots?id="+id, "")
|
||||||
|
want := 200
|
||||||
|
if protected {
|
||||||
|
want = 409
|
||||||
|
}
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if protected {
|
||||||
|
w = configCall(a, i, "DELETE", "backups?id="+strings.Repeat("a", 32), "")
|
||||||
|
if w.Code != 409 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotSizeError(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
id := "failed-restore-" + strings.Repeat("d", 32)
|
||||||
|
deep := filepath.Join(filepath.Dir(home), id)
|
||||||
|
for n := 0; n < 130; n++ {
|
||||||
|
deep = filepath.Join(deep, "d")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(deep, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "GET", "snapshots", "")
|
||||||
|
var got []snapshotInfo
|
||||||
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 1 || got[0].Size != -1 || got[0].SizeError == "" {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDelete(t *testing.T) {
|
||||||
|
for _, mode := range []string{"indexed", "legacy", "stale", "missing-parent", "index-error", "symlink"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
a.docker = nil
|
||||||
|
id := strings.Repeat("e", 32)
|
||||||
|
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := filepath.Join(dir, id+".dshbak")
|
||||||
|
if mode != "stale" && mode != "symlink" {
|
||||||
|
configWrite(t, file, "encrypted")
|
||||||
|
}
|
||||||
|
if mode == "symlink" {
|
||||||
|
if err := os.Symlink(t.TempDir(), file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "indexed" || mode == "stale" || mode == "missing-parent" {
|
||||||
|
if err := a.indexBackup(i, id, "manual", 2, 9); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "index-error" {
|
||||||
|
if _, err := a.s.DB.Exec("CREATE TABLE backups(broken TEXT)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "missing-parent" {
|
||||||
|
if err := os.RemoveAll(filepath.Join(a.root, "backups")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "DELETE", "backups?id="+id, "")
|
||||||
|
want := 200
|
||||||
|
if mode == "index-error" {
|
||||||
|
want = 500
|
||||||
|
}
|
||||||
|
if mode == "symlink" {
|
||||||
|
want = 400
|
||||||
|
}
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if want != 200 {
|
||||||
|
if _, err := os.Lstat(file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mode != "legacy" {
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM backups WHERE id=?", id).Scan(&count); err != nil || count != 0 {
|
||||||
|
t.Fatal(count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='backup.delete' AND target=? AND detail=?", i.ID, id).Scan(&count); err != nil || count != 1 {
|
||||||
|
t.Fatal(count, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementOwnershipRecheck(t *testing.T) {
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE instances SET uid='someone-else' WHERE id=?", i.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, action := range []string{"snapshots", "snapshots?id=pre-restore-" + strings.Repeat("a", 32), "backups?id=" + strings.Repeat("a", 32)} {
|
||||||
|
method := "DELETE"
|
||||||
|
if action == "snapshots" {
|
||||||
|
method = "GET"
|
||||||
|
}
|
||||||
|
if w := configCall(a, i, method, action, ""); w.Code != 404 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementAuditFailure(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
id := strings.Repeat("f", 32)
|
||||||
|
snapshot := filepath.Join(filepath.Dir(home), "pre-restore-"+id)
|
||||||
|
if err := os.Mkdir(snapshot, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := filepath.Join(dir, id+".dshbak")
|
||||||
|
configWrite(t, file, "encrypted")
|
||||||
|
if err := a.indexBackup(i, id, "manual", 2, 9); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.s.DB.Exec("DROP TABLE audit"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, action := range []string{"snapshots?id=pre-restore-" + id, "backups?id=" + id} {
|
||||||
|
if w := configCall(a, i, "DELETE", action, ""); w.Code != 500 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{snapshot, file} {
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM backups WHERE id=?", id).Scan(&count); err != nil || count != 1 {
|
||||||
|
t.Fatal(count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotScanCancelled(t *testing.T) {
|
||||||
|
root, err := os.OpenRoot(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := snapshotBytes(ctx, root, "."); err != context.Canceled {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotDeletionReleasesSQLConnection(t *testing.T) {
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
a.s.DB.SetMaxOpenConns(1)
|
||||||
|
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
a.deleteSnapshot(w, i, "snapshot", func() error {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
defer func() { close(release); <-done }()
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("removal did not start")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM audit WHERE action='snapshot.delete.started'").Scan(&count); err != nil || count != 1 {
|
||||||
|
t.Fatalf("SQL blocked during removal or initial audit not persisted: count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotDeletionAuditOutcomes(t *testing.T) {
|
||||||
|
for _, mode := range []string{"success", "remove-failed", "start-failed", "completion-audit-failed", "failure-audit-failed"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
if mode == "start-failed" {
|
||||||
|
if _, err := a.s.DB.Exec("DROP TABLE audit"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
called := false
|
||||||
|
a.deleteSnapshot(w, i, "snapshot", func() error {
|
||||||
|
called = true
|
||||||
|
if strings.Contains(mode, "audit-failed") {
|
||||||
|
if _, err := a.s.DB.Exec(`CREATE TRIGGER reject_terminal BEFORE INSERT ON audit WHEN NEW.action != 'snapshot.delete.started' BEGIN SELECT RAISE(FAIL, 'audit unavailable'); END`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "remove-failed" || mode == "failure-audit-failed" {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
want := 500
|
||||||
|
if mode == "success" {
|
||||||
|
want = 200
|
||||||
|
}
|
||||||
|
if w.Code != want || called != (mode != "start-failed") {
|
||||||
|
t.Fatal(w.Code, w.Body.String(), called)
|
||||||
|
}
|
||||||
|
if mode == "start-failed" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, state := range []string{"started", "completed", "failed"} {
|
||||||
|
wantCount := 0
|
||||||
|
if state == "started" || (state == "completed" && mode == "success") || (state == "failed" && mode == "remove-failed") {
|
||||||
|
wantCount = 1
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action=? AND uid=? AND target=? AND detail=?", "snapshot.delete."+state, i.UID, i.ID, "snapshot").Scan(&count); err != nil || count != wantCount {
|
||||||
|
t.Fatal(state, count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementPathErrorClassification(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unsafe", errUnsafeManagementPath, 400},
|
||||||
|
{"wrapped-unsafe", fmt.Errorf("path: %w", errUnsafeManagementPath), 400},
|
||||||
|
{"permission", &os.PathError{Op: "lstat", Path: "snapshot", Err: os.ErrPermission}, 500},
|
||||||
|
{"closed", os.ErrClosed, 500},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
managementPathError(w, tc.err)
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
base := t.TempDir()
|
||||||
|
configWrite(t, filepath.Join(base, "file"), "not a directory")
|
||||||
|
a.root = filepath.Join(base, "file", "child")
|
||||||
|
if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 500 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
a.root = filepath.Join(base, "missing")
|
||||||
|
if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := configCall(a, i, "DELETE", "snapshots?id=pre-restore-"+strings.Repeat("a", 32), ""); w.Code != 404 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-1
@@ -23,6 +23,18 @@ var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$")
|
|||||||
var configBodySlots = make(chan struct{}, 4)
|
var configBodySlots = make(chan struct{}, 4)
|
||||||
|
|
||||||
func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
|
func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
|
||||||
|
if action == "snapshots" && r.Method == http.MethodGet && r.URL.Query().Get("download") != "" {
|
||||||
|
a.snapshotDownload(w, r, i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if action == "snapshots" && r.Method == http.MethodPost {
|
||||||
|
a.snapshotRestore(w, r, i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if action == "snapshots" || (action == "backups" && r.Method == http.MethodDelete) {
|
||||||
|
a.backupManagement(w, r, i, action)
|
||||||
|
return
|
||||||
|
}
|
||||||
controller := http.NewResponseController(w)
|
controller := http.NewResponseController(w)
|
||||||
if r.Method != http.MethodGet || action != "backups" || r.URL.Query().Get("download") == "" {
|
if r.Method != http.MethodGet || action != "backups" || r.URL.Query().Get("download") == "" {
|
||||||
var flush func()
|
var flush func()
|
||||||
@@ -468,6 +480,15 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
snapshot := "pre-restore-" + store.ID()
|
snapshot := "pre-restore-" + store.ID()
|
||||||
|
failed := "failed-restore-" + store.ID()
|
||||||
|
if e = a.recordSnapshot(i, snapshot, i.Version, i.ImageID); e != nil {
|
||||||
|
fail(w, 500, "snapshot metadata unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e = a.recordSnapshot(i, failed, i.Version, i.ImageID); e != nil {
|
||||||
|
fail(w, 500, "snapshot metadata unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
if _, e = a.quotaSnapshot(i.UID, parent, restoredBytes); e != nil {
|
if _, e = a.quotaSnapshot(i.UID, parent, restoredBytes); e != nil {
|
||||||
fail(w, 413, "quota exceeded or snapshot size unavailable")
|
fail(w, 413, "quota exceeded or snapshot size unavailable")
|
||||||
return
|
return
|
||||||
@@ -536,7 +557,6 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a
|
|||||||
fail(w, 500, "restore rollback requires runtime stop")
|
fail(w, 500, "restore rollback requires runtime stop")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
failed := "failed-restore-" + store.ID()
|
|
||||||
if parent.Rename("home", failed) != nil || parent.Rename(snapshot, "home") != nil || upgradeSync(parent) != nil {
|
if parent.Rename("home", failed) != nil || parent.Rename(snapshot, "home") != nil || upgradeSync(parent) != nil {
|
||||||
a.s.SetInstance(i.ID, "error")
|
a.s.SetInstance(i.ID, "error")
|
||||||
fail(w, 500, "restore rollback requires administrator recovery")
|
fail(w, 500, "restore rollback requires administrator recovery")
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkspaceDownloadFilename(t *testing.T) {
|
||||||
|
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") })
|
||||||
|
if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home := filepath.Join(a.root, "workspaces/owner/instances/one/home")
|
||||||
|
checkFilename := func(t *testing.T, w *httptest.ResponseRecorder, want string) {
|
||||||
|
t.Helper()
|
||||||
|
header := w.Header().Get("Content-Disposition")
|
||||||
|
mediaType, params, err := mime.ParseMediaType(header)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse Content-Disposition %q: %v", header, err)
|
||||||
|
}
|
||||||
|
if mediaType != "attachment" || params["filename"] != want {
|
||||||
|
t.Fatalf("Content-Disposition = %q, want attachment with filename %q", header, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const content = "0123456789\nunchanged 下载 content\x00\xff"
|
||||||
|
for _, name := range []string{"report.txt", "中文文件.txt", "report with spaces.txt", `report";100%.txt`, "README"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
p := "parent/nested/" + name
|
||||||
|
file := filepath.Join(home, filepath.FromSlash(p))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(file), 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, partial := range []bool{false, true} {
|
||||||
|
r := runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid)
|
||||||
|
wantStatus, wantBody := http.StatusOK, content
|
||||||
|
if partial {
|
||||||
|
r.Header.Set("Range", "bytes=2-6")
|
||||||
|
wantStatus, wantBody = http.StatusPartialContent, content[2:7]
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.workspace(w, r)
|
||||||
|
if w.Code != wantStatus {
|
||||||
|
t.Fatalf("partial=%v: status = %d, want %d: %s", partial, w.Code, wantStatus, w.Body.String())
|
||||||
|
}
|
||||||
|
checkFilename(t, w, name)
|
||||||
|
if w.Body.String() != wantBody {
|
||||||
|
t.Errorf("partial=%v: body = %q, want %q", partial, w.Body.String(), wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("directory zip", func(t *testing.T) {
|
||||||
|
const name = `资料 ";100%`
|
||||||
|
p := "parent/" + name
|
||||||
|
dir := filepath.Join(home, filepath.FromSlash(p))
|
||||||
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "entry.txt"), []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.workspace(w, runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
checkFilename(t, w, name+".zip")
|
||||||
|
archive, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(archive.File) != 1 || archive.File[0].Name != "entry.txt" {
|
||||||
|
t.Fatalf("unexpected archive entries: %v", archive.File)
|
||||||
|
}
|
||||||
|
r, err := archive.File[0].Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
got, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != content {
|
||||||
|
t.Errorf("archive body = %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -781,7 +781,7 @@ func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root)
|
|||||||
fail(w, 400, "invalid file")
|
fail(w, 400, "invalid file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Disposition", "attachment")
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(p)}))
|
||||||
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
|
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ func TestRestoreSnapshotCopyFailureDoesNotConsumeRetention(t *testing.T) {
|
|||||||
}
|
}
|
||||||
configWrite(t, filepath.Join(base, name, "keep"), name)
|
configWrite(t, filepath.Join(base, name, "keep"), name)
|
||||||
}
|
}
|
||||||
if err := os.Symlink("missing-target", filepath.Join(home, "dangling")); err != nil {
|
if err := syscall.Mkfifo(filepath.Join(home, "dangling"), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID)
|
body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID)
|
||||||
|
|||||||
+23
-2
@@ -340,7 +340,7 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "profile-preview":
|
case "profile-preview":
|
||||||
a.profilePreview(w, r, i)
|
a.profilePreview(w, r, i)
|
||||||
return
|
return
|
||||||
case "profile", "backups", "restore":
|
case "profile", "backups", "restore", "snapshots":
|
||||||
a.config(w, r, i, action)
|
a.config(w, r, i, action)
|
||||||
return
|
return
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
@@ -535,6 +535,27 @@ func (a *API) runtimeOriginAllowed(r *http.Request) bool {
|
|||||||
|
|
||||||
var runtimeTransport = &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ResponseHeaderTimeout: 20 * time.Second, IdleConnTimeout: 90 * time.Second}
|
var runtimeTransport = &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ResponseHeaderTimeout: 20 * time.Second, IdleConnTimeout: 90 * time.Second}
|
||||||
|
|
||||||
|
// Office rendering can take longer than the normal API header deadline (the
|
||||||
|
// bundled converter defaults to 60 seconds). Reuse a separate connection pool
|
||||||
|
// without extending authentication or unrelated runtime requests.
|
||||||
|
var runtimeOfficeTransport = func() *http.Transport {
|
||||||
|
t := runtimeTransport.Clone()
|
||||||
|
t.ResponseHeaderTimeout = 120 * time.Second
|
||||||
|
return t
|
||||||
|
}()
|
||||||
|
|
||||||
|
type runtimeProxyTransport struct {
|
||||||
|
normal, office http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t runtimeProxyTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
// ReverseProxy has already removed the instance prefix here.
|
||||||
|
if r.URL.Path == "/api/officeToPdf/render" {
|
||||||
|
return t.office.RoundTrip(r)
|
||||||
|
}
|
||||||
|
return t.normal.RoundTrip(r)
|
||||||
|
}
|
||||||
|
|
||||||
type runtimeAuthKey struct {
|
type runtimeAuthKey struct {
|
||||||
api *API
|
api *API
|
||||||
id string
|
id string
|
||||||
@@ -1016,7 +1037,7 @@ func withoutRuntimeToken(rawQuery string) string {
|
|||||||
|
|
||||||
func runtimeReverseProxy(target *url.URL, prefix, host, cookies string) *httputil.ReverseProxy {
|
func runtimeReverseProxy(target *url.URL, prefix, host, cookies string) *httputil.ReverseProxy {
|
||||||
return &httputil.ReverseProxy{
|
return &httputil.ReverseProxy{
|
||||||
Transport: runtimeTransport, FlushInterval: -1, ErrorLog: log.New(io.Discard, "", 0),
|
Transport: runtimeProxyTransport{normal: runtimeTransport, office: runtimeOfficeTransport}, FlushInterval: -1, ErrorLog: log.New(io.Discard, "", 0),
|
||||||
Rewrite: func(p *httputil.ProxyRequest) {
|
Rewrite: func(p *httputil.ProxyRequest) {
|
||||||
p.SetURL(target)
|
p.SetURL(target)
|
||||||
p.Out.Host = host
|
p.Out.Host = host
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func officeTestProxy(t *testing.T, upstream string, officeTimeout time.Duration) *httputil.ReverseProxy {
|
||||||
|
t.Helper()
|
||||||
|
target, err := url.Parse(upstream)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
normal := runtimeTransport.Clone()
|
||||||
|
office := runtimeOfficeTransport.Clone()
|
||||||
|
normal.ResponseHeaderTimeout = 30 * time.Millisecond
|
||||||
|
office.ResponseHeaderTimeout = officeTimeout
|
||||||
|
t.Cleanup(normal.CloseIdleConnections)
|
||||||
|
t.Cleanup(office.CloseIdleConnections)
|
||||||
|
proxy := runtimeReverseProxy(target, "/dsh/office-test", "panel.test", "runtime=secret")
|
||||||
|
proxy.Transport = runtimeProxyTransport{normal: normal, office: office}
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeOfficeTransportDefaults(t *testing.T) {
|
||||||
|
if got := runtimeTransport.ResponseHeaderTimeout; got != 20*time.Second {
|
||||||
|
t.Errorf("normal timeout = %s, want 20s", got)
|
||||||
|
}
|
||||||
|
if got := runtimeOfficeTransport.ResponseHeaderTimeout; got != 120*time.Second {
|
||||||
|
t.Errorf("office timeout = %s, want 120s", got)
|
||||||
|
}
|
||||||
|
if runtimeTransport == runtimeOfficeTransport {
|
||||||
|
t.Fatal("office transport must be separate")
|
||||||
|
}
|
||||||
|
target, _ := url.Parse("http://panel.test")
|
||||||
|
proxy := runtimeReverseProxy(target, "/dsh/office-test", "panel.test", "")
|
||||||
|
transport, ok := proxy.Transport.(runtimeProxyTransport)
|
||||||
|
if !ok || transport.normal != runtimeTransport || transport.office != runtimeOfficeTransport {
|
||||||
|
t.Fatalf("unexpected default transport: %#v", proxy.Transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeOfficeProxyRouting(t *testing.T) {
|
||||||
|
for _, path := range []string{
|
||||||
|
"/api/officeToPdf/render",
|
||||||
|
"/api/status",
|
||||||
|
"/api/officeToPdf/render/",
|
||||||
|
"/api/officeToPdf/render/child",
|
||||||
|
"/api/officeToPdf/renderOther",
|
||||||
|
"/api/officetopdf/render",
|
||||||
|
"/api/officeToPdf/status",
|
||||||
|
} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
const body = "document payload"
|
||||||
|
const query = "file=report%20one.docx&mode=pdf&mode=preview"
|
||||||
|
var calls atomic.Int32
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
data, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil || string(data) != body || r.Method != http.MethodPost {
|
||||||
|
t.Errorf("upstream method/body = %s %q, error %v", r.Method, data, err)
|
||||||
|
}
|
||||||
|
if r.URL.Path != path || r.URL.RawQuery != query {
|
||||||
|
t.Errorf("upstream URL = %s", r.URL)
|
||||||
|
}
|
||||||
|
if r.Host != "panel.test" || r.Header.Get("X-Forwarded-Prefix") != "/dsh/office-test" || r.Header.Get("Cookie") != "runtime=secret" {
|
||||||
|
t.Errorf("upstream host/headers = %s %v", r.Host, r.Header)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
_, _ = io.WriteString(w, "rendered PDF")
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
proxy := officeTestProxy(t, upstream.URL, 500*time.Millisecond)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/dsh/office-test"+path+"?"+query+"&token=removed", strings.NewReader(body))
|
||||||
|
req.Header.Set("Cookie", "panel=private")
|
||||||
|
originalURL := req.URL.String()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
proxy.ServeHTTP(w, req)
|
||||||
|
want := http.StatusBadGateway
|
||||||
|
if path == "/api/officeToPdf/render" {
|
||||||
|
want = http.StatusOK
|
||||||
|
if w.Body.String() != "rendered PDF" {
|
||||||
|
t.Errorf("response body = %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.Code != want {
|
||||||
|
t.Errorf("status = %d, want %d: %s", w.Code, want, w.Body.String())
|
||||||
|
}
|
||||||
|
if req.URL.String() != originalURL || req.Header.Get("Cookie") != "panel=private" {
|
||||||
|
t.Error("proxy mutated incoming URL or cookies")
|
||||||
|
}
|
||||||
|
upstream.Close()
|
||||||
|
if got := calls.Load(); got != 1 {
|
||||||
|
t.Errorf("mutating request sent %d times, want exactly once", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeOfficeProxyFiniteTimeoutAndCancellation(t *testing.T) {
|
||||||
|
for _, name := range []string{"header timeout", "caller cancellation"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
started := make(chan struct{}, 2)
|
||||||
|
canceled := make(chan struct{}, 2)
|
||||||
|
var calls atomic.Int32
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
started <- struct{}{}
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
canceled <- struct{}{}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
officeTimeout := 100 * time.Millisecond
|
||||||
|
if name == "caller cancellation" {
|
||||||
|
officeTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
proxy := officeTestProxy(t, upstream.URL, officeTimeout)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/dsh/office-test/api/officeToPdf/render?mode=pdf", strings.NewReader("document")).WithContext(ctx)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
proxy.ServeHTTP(w, req)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("request never reached upstream")
|
||||||
|
}
|
||||||
|
if name == "caller cancellation" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("proxy did not promptly finish")
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want 502", w.Code)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-canceled:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("upstream context was not canceled")
|
||||||
|
}
|
||||||
|
upstream.Close()
|
||||||
|
if got := calls.Load(); got != 1 {
|
||||||
|
t.Errorf("mutating request sent %d times, want exactly once", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errSnapshotDownloadLimit = errors.New("snapshot archive exceeds size, entry or depth limit")
|
||||||
|
var errUnsafeSnapshotArchive = errors.New("unsafe snapshot link or unsupported file type")
|
||||||
|
|
||||||
|
type snapshotArchiveWriter struct {
|
||||||
|
ctx context.Context
|
||||||
|
w io.Writer
|
||||||
|
remaining int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *snapshotArchiveWriter) Write(p []byte) (int, error) {
|
||||||
|
if err := w.ctx.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if int64(len(p)) > w.remaining {
|
||||||
|
return 0, errSnapshotDownloadLimit
|
||||||
|
}
|
||||||
|
n, err := w.w.Write(p)
|
||||||
|
w.remaining -= int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotArchive(ctx context.Context, root *os.Root, out io.Writer, limit int64) error {
|
||||||
|
// Bound headers, padding and gzip expansion independently of file content.
|
||||||
|
gz := gzip.NewWriter(&snapshotArchiveWriter{ctx, out, limit + (256 << 20)})
|
||||||
|
tw := tar.NewWriter(&snapshotArchiveWriter{ctx, gz, limit + (128 << 20)})
|
||||||
|
remaining := limit
|
||||||
|
entries := 100000
|
||||||
|
var walk func(*os.Root, string, string, int) error
|
||||||
|
walk = func(parent *os.Root, name, rel string, depth int) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entries--
|
||||||
|
if entries < 0 || depth > 128 {
|
||||||
|
return errSnapshotDownloadLimit
|
||||||
|
}
|
||||||
|
st, err := parent.Lstat(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
link := ""
|
||||||
|
if st.Mode()&os.ModeSymlink != 0 {
|
||||||
|
link, err = parent.Readlink(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Archive link text only; do not read or traverse its target.
|
||||||
|
} else if !st.IsDir() && !st.Mode().IsRegular() {
|
||||||
|
return errUnsafeSnapshotArchive
|
||||||
|
}
|
||||||
|
if st.Mode().IsRegular() && st.Size() > remaining {
|
||||||
|
return errSnapshotDownloadLimit
|
||||||
|
}
|
||||||
|
if st.Mode().IsRegular() {
|
||||||
|
remaining -= st.Size()
|
||||||
|
}
|
||||||
|
h, err := tar.FileInfoHeader(st, link)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.Name = filepath.ToSlash(rel)
|
||||||
|
if st.IsDir() {
|
||||||
|
h.Name += "/"
|
||||||
|
}
|
||||||
|
if err = tw.WriteHeader(h); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if st.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if st.IsDir() {
|
||||||
|
child, err := parent.OpenRoot(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer child.Close()
|
||||||
|
actual, err := child.Stat(".")
|
||||||
|
if err != nil || !os.SameFile(st, actual) {
|
||||||
|
return errors.New("snapshot directory changed")
|
||||||
|
}
|
||||||
|
f, err := child.Open(".")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
for {
|
||||||
|
children, err := f.ReadDir(128)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range children {
|
||||||
|
if err := walk(child, entry.Name(), filepath.Join(rel, entry.Name()), depth+1); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := parent.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
actual, err := f.Stat()
|
||||||
|
if err != nil || !actual.Mode().IsRegular() || !os.SameFile(st, actual) || actual.Size() != st.Size() {
|
||||||
|
return errors.New("snapshot file changed")
|
||||||
|
}
|
||||||
|
if _, err = io.CopyN(tw, f, st.Size()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
after, err := f.Stat()
|
||||||
|
if err != nil || after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) {
|
||||||
|
return errors.New("snapshot file changed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := walk(root, ".", ".", 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return gz.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) snapshotDownload(w http.ResponseWriter, r *http.Request, i store.Instance) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
fail(w, 405, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := r.URL.Query()["download"]
|
||||||
|
if len(ids) != 1 || !snapshotIDPattern.MatchString(ids[0]) {
|
||||||
|
fail(w, 400, "invalid snapshot ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := ids[0]
|
||||||
|
select {
|
||||||
|
case zipSlots <- struct{}{}:
|
||||||
|
defer func() { <-zipSlots }()
|
||||||
|
default:
|
||||||
|
fail(w, 429, "too many archive downloads")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), zipTimeout)
|
||||||
|
defer cancel()
|
||||||
|
archive, err := os.CreateTemp("", "dsh-snapshot-*.tar.gz")
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "temporary archive unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.Remove(archive.Name())
|
||||||
|
defer archive.Close()
|
||||||
|
code := 500
|
||||||
|
err = func() error {
|
||||||
|
lock := a.instanceLocks.Acquire(i.ID)
|
||||||
|
defer lock.Release()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
current, err := a.s.GetInstance(i.ID)
|
||||||
|
if err != nil || current.UID != i.UID {
|
||||||
|
code = 404
|
||||||
|
return errors.New("not found")
|
||||||
|
}
|
||||||
|
root, err := managementRoot(a.root, "workspaces", current.UID, "instances", current.ID, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
code = 404
|
||||||
|
}
|
||||||
|
if errors.Is(err, errUnsafeManagementPath) {
|
||||||
|
code = 400
|
||||||
|
}
|
||||||
|
return errors.New("snapshot unavailable or unsafe path")
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
return snapshotArchive(ctx, root, archive, upgradeSnapshotLimit)
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errUnsafeSnapshotArchive) {
|
||||||
|
code = 400
|
||||||
|
}
|
||||||
|
if errors.Is(err, errSnapshotDownloadLimit) {
|
||||||
|
code = 413
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
code = 408
|
||||||
|
}
|
||||||
|
fail(w, code, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
fail(w, 408, "snapshot download timed out or canceled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = archive.Seek(0, io.SeekStart); err != nil {
|
||||||
|
fail(w, 500, "archive unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
controller := http.NewResponseController(w)
|
||||||
|
if err = controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||||
|
fail(w, 500, "download unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer controller.SetWriteDeadline(time.Time{})
|
||||||
|
a.s.AddAudit(i.UID, "snapshot.download", i.ID, id)
|
||||||
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": id + ".tar.gz"}))
|
||||||
|
http.ServeContent(w, r, id+".tar.gz", time.Time{}, archive)
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type snapshotTransferWriter struct {
|
||||||
|
*httptest.ResponseRecorder
|
||||||
|
check func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *snapshotTransferWriter) WriteHeader(code int) {
|
||||||
|
w.check()
|
||||||
|
w.ResponseRecorder.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotDownloadTransferUnlockedAndOversize(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("TMPDIR", tmp)
|
||||||
|
id := "failed-home-" + strings.Repeat("c", 32)
|
||||||
|
dir := filepath.Join(filepath.Dir(home), id)
|
||||||
|
if err := os.Mkdir(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configWrite(t, filepath.Join(dir, "data"), "saved")
|
||||||
|
checked := false
|
||||||
|
w := &snapshotTransferWriter{httptest.NewRecorder(), func() {
|
||||||
|
checked = true
|
||||||
|
if a.instanceLocks.held() {
|
||||||
|
t.Fatal("instance locked during transfer")
|
||||||
|
}
|
||||||
|
files, err := os.ReadDir(tmp)
|
||||||
|
if err != nil || len(files) != 1 {
|
||||||
|
t.Fatal(files, err)
|
||||||
|
}
|
||||||
|
st, err := files[0].Info()
|
||||||
|
if err != nil || st.Mode().Perm() != 0600 {
|
||||||
|
t.Fatal(st, err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
a.snapshotDownload(w, httptest.NewRequest(http.MethodGet, "/?download="+id, nil), i)
|
||||||
|
if !checked || w.Code != 200 {
|
||||||
|
t.Fatal(checked, w.Code)
|
||||||
|
}
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
if _, err := io.Copy(io.Discard, gz); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f, err := os.Create(filepath.Join(dir, "huge"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = f.Truncate(upgradeSnapshotLimit + 1)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
a.snapshotDownload(rec, httptest.NewRequest("GET", "/?download="+id, nil), i)
|
||||||
|
if rec.Code != 413 || !strings.Contains(rec.Body.String(), "limit") {
|
||||||
|
t.Fatal(rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
files, err := os.ReadDir(tmp)
|
||||||
|
if err != nil || len(files) != 0 {
|
||||||
|
t.Fatal(files, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotDownloadContentsAndCleanup(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, true)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("TMPDIR", tmp)
|
||||||
|
id := "pre-restore-" + strings.Repeat("a", 32)
|
||||||
|
dir := filepath.Join(filepath.Dir(home), id)
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "node_modules/.pnpm/pkg"), 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configWrite(t, filepath.Join(dir, "node_modules/.pnpm/pkg/bin"), "#!/bin/sh")
|
||||||
|
if err := os.Chmod(filepath.Join(dir, "node_modules/.pnpm/pkg/bin"), 0751); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(".pnpm/pkg", filepath.Join(dir, "node_modules/pkg")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configWrite(t, filepath.Join(dir, ".credentials.yaml"), "secret")
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.config(w, httptest.NewRequest("GET", "/?download="+id, nil), i, "snapshots")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
_, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
|
||||||
|
if err != nil || params["filename"] != id+".tar.gz" {
|
||||||
|
t.Fatal(params, err)
|
||||||
|
}
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen[h.Name] = true
|
||||||
|
b, err := io.ReadAll(tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch h.Name {
|
||||||
|
case "node_modules/.pnpm/pkg/bin":
|
||||||
|
if string(b) != "#!/bin/sh" || h.Mode != 0751 {
|
||||||
|
t.Fatal(h, string(b))
|
||||||
|
}
|
||||||
|
case "node_modules/pkg":
|
||||||
|
if h.Typeflag != tar.TypeSymlink || h.Linkname != ".pnpm/pkg" {
|
||||||
|
t.Fatal(h)
|
||||||
|
}
|
||||||
|
case ".credentials.yaml":
|
||||||
|
if string(b) != "secret" {
|
||||||
|
t.Fatal(string(b))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seen) != 7 || !seen["node_modules/pkg"] || seen["node_modules/pkg/bin"] {
|
||||||
|
t.Fatal(seen)
|
||||||
|
}
|
||||||
|
files, err := os.ReadDir(tmp)
|
||||||
|
if err != nil || len(files) != 0 {
|
||||||
|
t.Fatal(files, err)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("GET", "/?download="+id, nil)
|
||||||
|
r.Header.Set("Range", "bytes=0-9")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
a.snapshotDownload(w, r, i)
|
||||||
|
if w.Code != 206 || w.Body.Len() != 10 {
|
||||||
|
t.Fatal(w.Code, w.Body.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotDownloadRejectsUnsafeAndForeign(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
id := "pre-restore-" + strings.Repeat("b", 32)
|
||||||
|
dir := filepath.Join(filepath.Dir(home), id)
|
||||||
|
if err := os.Mkdir(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, query := range []string{"", "../home", "/home", id + "/../home", "pre-restore-ABC"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+url.QueryEscape(query), nil), i)
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Fatal(query, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreign := i
|
||||||
|
foreign.UID = "someone-else"
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), foreign)
|
||||||
|
if w.Code != 404 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
for _, target := range []string{"../home", "/workspace/example", "../../elsewhere", "missing", "link"} {
|
||||||
|
if err := os.Symlink(target, filepath.Join(dir, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
|
||||||
|
if w.Code != 200 || w.Header().Get("Content-Disposition") == "" {
|
||||||
|
t.Fatal(target, w.Code)
|
||||||
|
}
|
||||||
|
gz, err := gzip.NewReader(w.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
found := false
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if h.Name == "link" {
|
||||||
|
found = h.Typeflag == tar.TypeSymlink && h.Linkname == target && h.Size == 0
|
||||||
|
} else if h.Name != "./" {
|
||||||
|
t.Fatalf("unexpected archived entry: %s", h.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gz.Close()
|
||||||
|
if !found {
|
||||||
|
t.Fatal("link text was not preserved")
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(dir, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := syscall.Mkfifo(filepath.Join(dir, "fifo"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(dir, "fifo")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(home, dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
a.snapshotDownload(w, httptest.NewRequest("GET", "/?download="+id, nil), i)
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotArchiveLimitsAndCancellation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configWrite(t, filepath.Join(dir, "file"), strings.Repeat("x", 2048))
|
||||||
|
root, err := os.OpenRoot(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
if err := snapshotArchive(context.Background(), root, io.Discard, 1024); !errors.Is(err, errSnapshotDownloadLimit) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := snapshotArchive(context.Background(), root, io.Discard, 2048); err != nil {
|
||||||
|
t.Fatal("exact content limit should allow archive overhead", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if err := snapshotArchive(ctx, root, io.Discard, upgradeSnapshotLimit); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("TMPDIR", tmp)
|
||||||
|
r := httptest.NewRequest("GET", "/?download=pre-restore-"+strings.Repeat("a", 32), nil).WithContext(ctx)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.snapshotDownload(w, r, i)
|
||||||
|
if w.Code != 408 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
files, err := os.ReadDir(tmp)
|
||||||
|
if err != nil || len(files) != 0 {
|
||||||
|
t.Fatal(files, err)
|
||||||
|
}
|
||||||
|
if len(zipSlots) != 0 || a.instanceLocks.held() {
|
||||||
|
t.Fatal("resource leak")
|
||||||
|
}
|
||||||
|
t.Setenv("TMPDIR", filepath.Join(tmp, "missing"))
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
a.snapshotDownload(w, httptest.NewRequest("GET", r.URL.String(), nil), i)
|
||||||
|
if w.Code != 500 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const snapshotUnknownReason = "snapshot source image/version unknown; explicit acknowledgement required"
|
||||||
|
|
||||||
|
func snapshotRuntimeLayout(config, host map[string]json.RawMessage, mounts []struct{ Type, Source, Destination string }, home string) bool {
|
||||||
|
workspace := 0
|
||||||
|
for _, mount := range mounts {
|
||||||
|
if mount.Type == "tmpfs" && mount.Destination == "/tmp" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mount.Type != "bind" || mount.Source != home || mount.Destination != "/workspace" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
workspace++
|
||||||
|
}
|
||||||
|
if workspace != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var env []string
|
||||||
|
if json.Unmarshal(config["Env"], &env) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
values := map[string]string{}
|
||||||
|
for _, entry := range env {
|
||||||
|
key, value, _ := strings.Cut(entry, "=")
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
for key, want := range map[string]string{"DSH_HOME": "/workspace", "HOME": "/workspace", "XDG_DATA_HOME": "/workspace/.local/share", "XDG_CONFIG_HOME": "/workspace/.config"} {
|
||||||
|
if values[key] != want {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index, raw := range []json.RawMessage{host["Tmpfs"], config["Volumes"]} {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var mounts map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(raw, &mounts) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if index == 0 {
|
||||||
|
delete(mounts, "/tmp")
|
||||||
|
}
|
||||||
|
if len(mounts) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) recordSnapshot(i store.Instance, id, version, image string) error {
|
||||||
|
_, err := a.s.DB.Exec("CREATE TABLE IF NOT EXISTS snapshot_metadata (instance_id TEXT NOT NULL, id TEXT NOT NULL, source_version TEXT NOT NULL, source_image TEXT NOT NULL, PRIMARY KEY(instance_id,id))")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = a.s.DB.Exec("INSERT INTO snapshot_metadata(instance_id,id,source_version,source_image) VALUES(?,?,?,?)", i.ID, id, version, image)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) snapshotCompatibility(i store.Instance, id string) (version, image, reason string) {
|
||||||
|
var exists int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='snapshot_copy_state'").Scan(&exists); err != nil {
|
||||||
|
return "", "", "snapshot completion metadata unavailable"
|
||||||
|
}
|
||||||
|
if exists != 0 {
|
||||||
|
var ready int
|
||||||
|
err := a.s.DB.QueryRow("SELECT ready FROM snapshot_copy_state WHERE instance_id=? AND id=?", i.ID, id).Scan(&ready)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", "", "snapshot completion metadata unavailable"
|
||||||
|
}
|
||||||
|
if err == nil && ready != 1 {
|
||||||
|
return "", "", "snapshot copy incomplete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='snapshot_metadata'").Scan(&exists); err != nil {
|
||||||
|
return "", "", "snapshot metadata unavailable"
|
||||||
|
}
|
||||||
|
if exists != 0 {
|
||||||
|
err := a.s.DB.QueryRow("SELECT source_version,source_image FROM snapshot_metadata WHERE instance_id=? AND id=?", i.ID, id).Scan(&version, &image)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", "", "snapshot metadata unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (version != "" && version != i.Version) || (image != "" && image != i.ImageID) {
|
||||||
|
return version, image, "snapshot source image/version is incompatible"
|
||||||
|
}
|
||||||
|
if version == "" || image == "" {
|
||||||
|
return version, image, snapshotUnknownReason
|
||||||
|
}
|
||||||
|
return version, image, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) snapshotRestore(w http.ResponseWriter, r *http.Request, i store.Instance) {
|
||||||
|
controller := http.NewResponseController(w)
|
||||||
|
w, flush := deferJSONResponse(w)
|
||||||
|
defer flush()
|
||||||
|
select {
|
||||||
|
case configBodySlots <- struct{}{}:
|
||||||
|
defer func() { r.Body = http.NoBody; <-configBodySlots }()
|
||||||
|
default:
|
||||||
|
fail(w, 503, "configuration uploads busy; retry later")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !configReadBody(w, r, controller, 4096) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
ID string
|
||||||
|
Password string
|
||||||
|
Confirm bool
|
||||||
|
AllowUnknown bool `json:"allow_unknown_version"`
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !snapshotIDPattern.MatchString(in.ID) || !in.Confirm {
|
||||||
|
fail(w, 400, "valid snapshot ID and explicit confirmation required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lock := a.instanceLocks.Acquire(i.ID)
|
||||||
|
defer lock.Release()
|
||||||
|
a.readersMu.Lock()
|
||||||
|
readers := a.workspaceReaders[i.ID]
|
||||||
|
a.readersMu.Unlock()
|
||||||
|
if readers > 0 {
|
||||||
|
fail(w, 409, "instance files busy; retry later")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := a.userLocks.Acquire(i.UID)
|
||||||
|
defer user.Release()
|
||||||
|
current, err := a.s.GetInstance(i.ID)
|
||||||
|
if err != nil || current.UID != i.UID {
|
||||||
|
fail(w, 404, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i = current
|
||||||
|
if !a.reauthenticate(w, i.UID, in.Password) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if reason := a.backupProtection(i); reason != "" {
|
||||||
|
fail(w, 409, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
admissionMu.RLock()
|
||||||
|
defer admissionMu.RUnlock()
|
||||||
|
buildQueue.Lock()
|
||||||
|
err = a.ensureBuilds()
|
||||||
|
buildQueue.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var blocked int
|
||||||
|
if err = a.s.DB.QueryRowContext(r.Context(), "SELECT count(*) FROM image_admissions WHERE image_id=? AND state='blocked'", i.ImageID).Scan(&blocked); err != nil {
|
||||||
|
fail(w, 500, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if blocked > 0 {
|
||||||
|
fail(w, 409, "image is blocked; restore denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
version, image, reason := a.snapshotCompatibility(i, in.ID)
|
||||||
|
if reason != "" && !(reason == snapshotUnknownReason && in.AllowUnknown) {
|
||||||
|
fail(w, 409, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, err := managementRoot(a.root, "workspaces", i.UID, "instances", i.ID)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
fail(w, 404, "home or snapshot not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
managementPathError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
for _, name := range []string{"home", in.ID} {
|
||||||
|
st, e := root.Lstat(name)
|
||||||
|
if errors.Is(e, os.ErrNotExist) {
|
||||||
|
fail(w, 404, "home or snapshot not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
managementPathError(w, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !st.IsDir() {
|
||||||
|
fail(w, 400, "unsafe home or snapshot")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := root.Open(".")
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "snapshot storage unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := f.ReadDir(-1)
|
||||||
|
f.Close()
|
||||||
|
count := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if strings.HasPrefix(entry.Name(), "pre-restore-") || strings.HasPrefix(entry.Name(), "failed-restore-") {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "snapshot listing failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if count >= 3 {
|
||||||
|
fail(w, 409, "restore snapshot limit reached; cleanup required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
size, err := quotaTreeBytes(root, in.ID)
|
||||||
|
var pathError *os.PathError
|
||||||
|
if errors.As(err, &pathError) {
|
||||||
|
managementPathError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || a.quotaGrowth(i.UID, size) != nil {
|
||||||
|
fail(w, 413, "quota exceeded or snapshot size unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
var container struct {
|
||||||
|
Image string
|
||||||
|
Config, HostConfig map[string]json.RawMessage
|
||||||
|
Mounts []struct{ Type, Source, Destination string }
|
||||||
|
State struct{ Running, Paused, Restarting bool }
|
||||||
|
}
|
||||||
|
err = a.docker.Do(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/json", nil, &container)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 502, "container inspection failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if container.State.Paused || container.State.Restarting {
|
||||||
|
fail(w, 409, "unsupported container state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var runtimeImage struct {
|
||||||
|
ID string `json:"Id"`
|
||||||
|
Config struct{ Labels map[string]string }
|
||||||
|
}
|
||||||
|
hostRoot := os.Getenv("HOST_DATA_ROOT")
|
||||||
|
if hostRoot == "" {
|
||||||
|
hostRoot = a.root
|
||||||
|
}
|
||||||
|
homeSource, homeErr := toHostPath(a.root, hostRoot, filepath.Join(root.Name(), "home"))
|
||||||
|
if homeErr != nil || !snapshotRuntimeLayout(container.Config, container.HostConfig, container.Mounts, homeSource) {
|
||||||
|
fail(w, 409, "incompatible effective runtime workspace layout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if i.ImageID == "" || container.Image != i.ImageID || container.Config == nil || container.HostConfig == nil || homeErr != nil || a.docker.Do(ctx, "GET", "/images/"+url.PathEscape(i.ImageID)+"/json", nil, &runtimeImage) != nil || runtimeImage.ID != i.ImageID || runtimeImage.Config.Labels["dsh-mgr.version"] != i.Version || upgradeWorkspace(container.Config, container.HostConfig, runtimeImage.Config.Labels["dsh-mgr.recipe"], homeSource) != nil {
|
||||||
|
fail(w, 409, "incompatible runtime image or workspace layout")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wasRunning := container.State.Running
|
||||||
|
op := store.ID()
|
||||||
|
staging := ".snapshot-restore-" + op
|
||||||
|
previous := "pre-restore-" + op
|
||||||
|
failed := "failed-restore-" + op
|
||||||
|
if err = upgradeCopy(ctx, root, in.ID, staging, size); err != nil {
|
||||||
|
if root.RemoveAll(staging) != nil {
|
||||||
|
a.s.SetInstance(i.ID, "error")
|
||||||
|
fail(w, 500, "staging cleanup requires administrator recovery")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.As(err, &pathError) {
|
||||||
|
managementPathError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(w, 400, "unsafe or oversized snapshot")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if root.RemoveAll(staging) != nil {
|
||||||
|
a.s.SetInstance(i.ID, "error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err = a.recordSnapshot(i, previous, i.Version, i.ImageID); err != nil {
|
||||||
|
fail(w, 500, "snapshot metadata unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = a.recordSnapshot(i, failed, version, image); err != nil {
|
||||||
|
fail(w, 500, "snapshot metadata unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID); err != nil {
|
||||||
|
fail(w, 500, "cannot record restore state")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = a.s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "snapshot.restore.started", i.ID, in.ID+" "+previous+" "+failed, time.Now().Unix()); err != nil {
|
||||||
|
fail(w, 500, "restore audit unavailable; recovery required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = a.configStop(ctx, i.ContainerID); err != nil {
|
||||||
|
fail(w, 502, "runtime stop failed; recovery required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
moved, installed := false, false
|
||||||
|
var owner store.User
|
||||||
|
owner.ID = i.UID
|
||||||
|
err = a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", i.UID).Scan(&owner.Quota)
|
||||||
|
if err == nil {
|
||||||
|
var used int64
|
||||||
|
used, err = a.userUsage(owner)
|
||||||
|
if err == nil && used > owner.Quota {
|
||||||
|
err = errors.New("quota exceeded after runtime stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = root.Rename("home", previous)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
moved = true
|
||||||
|
err = upgradeSync(root)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = root.Rename(staging, "home")
|
||||||
|
installed = err == nil
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = upgradeSync(root)
|
||||||
|
}
|
||||||
|
startReady := func(c context.Context) error {
|
||||||
|
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
|
||||||
|
if e := a.docker.Do(c, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return a.upgradeReady(c, i.ContainerID, i.ID, a.runtimeHost(r))
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = startReady(ctx)
|
||||||
|
}
|
||||||
|
if err == nil && !wasRunning {
|
||||||
|
err = a.configStop(ctx, i.ContainerID)
|
||||||
|
}
|
||||||
|
rejected := err != nil
|
||||||
|
if rejected {
|
||||||
|
rollback, done := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer done()
|
||||||
|
if a.configStop(rollback, i.ContainerID) != nil {
|
||||||
|
fail(w, 500, "restore rollback requires runtime stop; recovery required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if installed {
|
||||||
|
if root.Rename("home", failed) != nil || upgradeSync(root) != nil {
|
||||||
|
fail(w, 500, "restore rollback requires administrator recovery")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if moved {
|
||||||
|
if root.Rename(previous, "home") != nil || upgradeSync(root) != nil {
|
||||||
|
fail(w, 500, "restore rollback requires administrator recovery")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wasRunning {
|
||||||
|
if startReady(rollback) != nil {
|
||||||
|
fail(w, 500, "restore rollback readiness failed; recovery required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := "exited"
|
||||||
|
if wasRunning {
|
||||||
|
status = "running"
|
||||||
|
}
|
||||||
|
if _, err = a.s.DB.Exec("UPDATE instances SET status=? WHERE id=?", status, i.ID); err != nil {
|
||||||
|
fail(w, 500, "restore state requires administrator recovery")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID})
|
||||||
|
if rejected {
|
||||||
|
a.s.AddAudit(i.UID, "snapshot.restore.rolled_back", i.ID, in.ID)
|
||||||
|
fail(w, 400, "restore rejected; previous home restored")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.s.AddAudit(i.UID, "snapshot.restore.completed", i.ID, in.ID)
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "snapshot_id": previous})
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/docker"
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func snapshotRestoreFixture(t *testing.T, running bool, failure string) (*API, store.Instance, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
a, i, home := configTestAPI(t, running, failure)
|
||||||
|
i.ImageID = "sha256:" + strings.Repeat("a", 64)
|
||||||
|
i.Version = "1.0"
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE instances SET image_id=?,version=? WHERE id=?", i.ImageID, i.Version, i.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
original := a.docker
|
||||||
|
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.Contains(r.URL.Path, "/images/") {
|
||||||
|
writeJSON(w, map[string]any{"Id": i.ImageID, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.recipe": "7", "dsh-mgr.version": i.Version}}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/json") {
|
||||||
|
var data map[string]any
|
||||||
|
if err := original.Do(r.Context(), "GET", r.URL.RequestURI(), nil, &data); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
w.WriteHeader(500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data["Image"] = i.ImageID
|
||||||
|
data["Config"] = map[string]any{"Env": []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}}
|
||||||
|
data["Mounts"] = []map[string]string{{"Type": "bind", "Source": home, "Destination": "/workspace"}}
|
||||||
|
data["HostConfig"] = map[string]any{"Mounts": []map[string]string{{"Type": "bind", "Source": home, "Target": "/workspace"}}}
|
||||||
|
data["HostConfig"].(map[string]any)["Tmpfs"] = map[string]string{"/tmp": "rw,noexec,nosuid,size=64m"}
|
||||||
|
writeJSON(w, data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := original.Stream(r.Context(), r.Method, r.URL.RequestURI(), nil, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
w.WriteHeader(500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(proxy.Close)
|
||||||
|
a.docker = docker.New(proxy.URL)
|
||||||
|
id := "upgrade-" + strings.Repeat("b", 32) + "-snapshot"
|
||||||
|
if err := os.Mkdir(filepath.Join(filepath.Dir(home), id), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configWrite(t, filepath.Join(filepath.Dir(home), id, "data"), "snapshot")
|
||||||
|
configWrite(t, filepath.Join(home, "data"), "current")
|
||||||
|
return a, i, home, id
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotRestoreBody(id string, unknown bool) string {
|
||||||
|
return fmt.Sprintf(`{"id":%q,"password":"original-password","confirm":true,"allow_unknown_version":%t}`, id, unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotRuntimeLayout(t *testing.T) {
|
||||||
|
for _, test := range []string{"valid", "env", "tmpfs", "volume", "effective"} {
|
||||||
|
t.Run(test, func(t *testing.T) {
|
||||||
|
env := []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}
|
||||||
|
if test == "env" {
|
||||||
|
env = append(env, "DSH_HOME=/old-shared")
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(env)
|
||||||
|
config := map[string]json.RawMessage{"Env": raw}
|
||||||
|
host := map[string]json.RawMessage{"Tmpfs": json.RawMessage(`{"/tmp":"rw"}`)}
|
||||||
|
mounts := []struct{ Type, Source, Destination string }{{"bind", "/home", "/workspace"}}
|
||||||
|
mounts = append(mounts, struct{ Type, Source, Destination string }{"tmpfs", "", "/tmp"})
|
||||||
|
if test == "tmpfs" {
|
||||||
|
host["Tmpfs"] = json.RawMessage(`{"/workspace/data":"rw"}`)
|
||||||
|
}
|
||||||
|
if test == "volume" {
|
||||||
|
config["Volumes"] = json.RawMessage(`{"/workspace/data":{}}`)
|
||||||
|
}
|
||||||
|
if test == "effective" {
|
||||||
|
mounts = append(mounts, struct{ Type, Source, Destination string }{"volume", "/other", "/workspace/data"})
|
||||||
|
}
|
||||||
|
if snapshotRuntimeLayout(config, host, mounts, "/home") != (test == "valid") {
|
||||||
|
t.Fatal(test)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotListProvenance(t *testing.T) {
|
||||||
|
a, i, _, id := snapshotRestoreFixture(t, false, "")
|
||||||
|
for _, known := range []bool{false, true} {
|
||||||
|
if known {
|
||||||
|
if err := a.recordSnapshot(i, id, "old-version", i.ImageID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "GET", "snapshots", "")
|
||||||
|
var list []snapshotInfo
|
||||||
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &list) != nil || len(list) != 1 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if list[0].Restorable == known || list[0].RestoreReason == "" {
|
||||||
|
t.Fatal(list)
|
||||||
|
}
|
||||||
|
if !known && (list[0].SourceVersion != "" || list[0].SourceImage != "") {
|
||||||
|
t.Fatal("inferred provenance", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotFullRestore(t *testing.T) {
|
||||||
|
for _, running := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprint(running), func(t *testing.T) {
|
||||||
|
a, i, home, id := snapshotRestoreFixture(t, running, "")
|
||||||
|
links := map[string]string{"absolute": "/workspace/example", "dangling": "missing", "loop": "loop", "relative": "data"}
|
||||||
|
for name, target := range links {
|
||||||
|
if err := os.Symlink(target, filepath.Join(filepath.Dir(home), id, name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, false))
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
SnapshotID string `json:"snapshot_id"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &result)
|
||||||
|
for path, want := range map[string]string{filepath.Join(home, "data"): "snapshot", filepath.Join(filepath.Dir(home), id, "data"): "snapshot", filepath.Join(filepath.Dir(home), result.SnapshotID, "data"): "current"} {
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(got) != want {
|
||||||
|
t.Fatal(path, string(got), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current, _ := a.s.GetInstance(i.ID)
|
||||||
|
for name, target := range links {
|
||||||
|
got, err := os.Readlink(filepath.Join(home, name))
|
||||||
|
if err != nil || got != target {
|
||||||
|
t.Fatalf("restored link %s: %q %v", name, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := "exited"
|
||||||
|
if running {
|
||||||
|
want = "running"
|
||||||
|
}
|
||||||
|
if current.Status != want {
|
||||||
|
t.Fatal(current.Status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotRestoreRollback(t *testing.T) {
|
||||||
|
a, i, home, id := snapshotRestoreFixture(t, false, "reconcile")
|
||||||
|
w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, true))
|
||||||
|
if w.Code != 400 || !strings.Contains(w.Body.String(), "previous home restored") {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
for path, want := range map[string]string{filepath.Join(home, "data"): "current", filepath.Join(filepath.Dir(home), id, "data"): "snapshot"} {
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(got) != want {
|
||||||
|
t.Fatal(path, string(got), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(filepath.Dir(home), "failed-restore-*", "data"))
|
||||||
|
if len(matches) != 1 {
|
||||||
|
t.Fatal(matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIncompleteSnapshotNotRestorable(t *testing.T) {
|
||||||
|
a, i, _, id := snapshotRestoreFixture(t, false, "")
|
||||||
|
if _, err := a.s.DB.Exec("CREATE TABLE snapshot_copy_state (instance_id TEXT, id TEXT, ready INTEGER)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.s.DB.Exec("INSERT INTO snapshot_copy_state VALUES(?,?,0)", i.ID, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "GET", "snapshots", "")
|
||||||
|
var list []snapshotInfo
|
||||||
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &list) != nil || len(list) != 1 || list[0].Restorable || list[0].RestoreReason != "snapshot copy incomplete" {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, true))
|
||||||
|
if w.Code != 409 || !strings.Contains(w.Body.String(), "snapshot copy incomplete") {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE snapshot_copy_state SET ready=1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, reason := a.snapshotCompatibility(i, id); reason != "" {
|
||||||
|
t.Fatal(reason)
|
||||||
|
}
|
||||||
|
if _, err := a.s.DB.Exec("ALTER TABLE snapshot_copy_state RENAME COLUMN ready TO unavailable"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, reason := a.snapshotCompatibility(i, id); reason != "snapshot completion metadata unavailable" {
|
||||||
|
t.Fatal(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotRestoreGuards(t *testing.T) {
|
||||||
|
for _, test := range []string{"unknown", "mismatch", "password", "confirm", "reader", "quota", "error", "unsafe", "foreign", "oversized"} {
|
||||||
|
t.Run(test, func(t *testing.T) {
|
||||||
|
a, i, home, id := snapshotRestoreFixture(t, false, "")
|
||||||
|
body := snapshotRestoreBody(id, true)
|
||||||
|
want := 409
|
||||||
|
switch test {
|
||||||
|
case "unknown":
|
||||||
|
body = snapshotRestoreBody(id, false)
|
||||||
|
case "mismatch":
|
||||||
|
if err := a.recordSnapshot(i, id, "different", i.ImageID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case "password":
|
||||||
|
body = strings.ReplaceAll(body, "original-password", "wrong")
|
||||||
|
want = 403
|
||||||
|
case "confirm":
|
||||||
|
body = strings.ReplaceAll(body, `"confirm":true`, `"confirm":false`)
|
||||||
|
want = 400
|
||||||
|
case "reader":
|
||||||
|
a.workspaceReaders = map[string]int{i.ID: 1}
|
||||||
|
case "quota":
|
||||||
|
a.s.DB.Exec("UPDATE users SET quota=1 WHERE id=?", i.UID)
|
||||||
|
want = 413
|
||||||
|
case "error":
|
||||||
|
a.s.SetInstance(i.ID, "error")
|
||||||
|
case "unsafe":
|
||||||
|
if err := syscall.Mkfifo(filepath.Join(filepath.Dir(home), id, "unsafe"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want = 400
|
||||||
|
case "foreign":
|
||||||
|
i.UID = "someone-else"
|
||||||
|
want = 404
|
||||||
|
case "oversized":
|
||||||
|
body = strings.Repeat("x", 4097)
|
||||||
|
want = 413
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "POST", "snapshots", body)
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(filepath.Join(home, "data"))
|
||||||
|
if err != nil || string(got) != "current" {
|
||||||
|
t.Fatal(string(got), err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-20
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -100,9 +101,17 @@ func upgradeSync(r *os.Root) error {
|
|||||||
|
|
||||||
func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit int64) error {
|
func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit int64) error {
|
||||||
remaining, entries := limit, 100000
|
remaining, entries := limit, 100000
|
||||||
var links []string
|
|
||||||
var copyTree func(string, string, int) error
|
var copyTree func(string, string, int) error
|
||||||
copyTree = func(src, dst string, depth int) error {
|
copyTree = func(src, dst string, depth int) (copyErr error) {
|
||||||
|
defer func() {
|
||||||
|
if copyErr != nil {
|
||||||
|
if pathErr, ok := copyErr.(*os.PathError); ok {
|
||||||
|
copyErr = fmt.Errorf("snapshot copy: %w", &os.PathError{Op: pathErr.Op, Path: src, Err: pathErr.Err})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
copyErr = fmt.Errorf("%s: %w", src, copyErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -155,14 +164,11 @@ func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit i
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rel, err := filepath.Rel(source, src)
|
// Preserve the link itself, including dangling and container-absolute
|
||||||
if err != nil || filepath.IsAbs(link) || !filepath.IsLocal(filepath.Join(filepath.Dir(rel), link)) {
|
// targets. Never resolve it against the panel's filesystem.
|
||||||
return errors.New("unsafe snapshot link")
|
|
||||||
}
|
|
||||||
if err = r.Symlink(link, dst); err != nil {
|
if err = r.Symlink(link, dst); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
links = append(links, rel)
|
|
||||||
return r.Lchown(dst, 1000, 1000)
|
return r.Lchown(dst, 1000, 1000)
|
||||||
}
|
}
|
||||||
if !st.Mode().IsRegular() || st.Size() > remaining {
|
if !st.Mode().IsRegular() || st.Size() > remaining {
|
||||||
@@ -201,17 +207,6 @@ func upgradeCopy(ctx context.Context, r *os.Root, source, target string, limit i
|
|||||||
if err := copyTree(source, target, 0); err != nil {
|
if err := copyTree(source, target, 0); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
snapshot, err := r.OpenRoot(target)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer snapshot.Close()
|
|
||||||
// Resolve only within the completed snapshot, including intermediate symlinks.
|
|
||||||
for _, link := range links {
|
|
||||||
if _, err := snapshot.Stat(link); err != nil {
|
|
||||||
return errors.New("unsafe or unresolved snapshot link")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return upgradeSync(r)
|
return upgradeSync(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +335,10 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance)
|
|||||||
}
|
}
|
||||||
op := store.ID()
|
op := store.ID()
|
||||||
snapshot, failedHome := "upgrade-"+op+"-snapshot", "failed-home-"+op
|
snapshot, failedHome := "upgrade-"+op+"-snapshot", "failed-home-"+op
|
||||||
|
if a.recordSnapshot(i, snapshot, i.Version, i.ImageID) != nil || a.recordSnapshot(i, failedHome, image.Config.Labels["dsh-mgr.version"], image.ID) != nil {
|
||||||
|
fail(w, 500, "snapshot metadata unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
newName := runtimeNetwork(i.ID) + "-upgrade-" + op
|
newName := runtimeNetwork(i.ID) + "-upgrade-" + op
|
||||||
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS upgrade_journal (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, old_container TEXT NOT NULL, new_container TEXT NOT NULL DEFAULT '', new_name TEXT NOT NULL, image_id TEXT NOT NULL, snapshot TEXT NOT NULL, failed_home TEXT NOT NULL, phase TEXT NOT NULL)`)
|
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS upgrade_journal (id TEXT PRIMARY KEY, instance_id TEXT NOT NULL, old_container TEXT NOT NULL, new_container TEXT NOT NULL DEFAULT '', new_name TEXT NOT NULL, image_id TEXT NOT NULL, snapshot TEXT NOT NULL, failed_home TEXT NOT NULL, phase TEXT NOT NULL)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -456,8 +455,38 @@ func (a *API) upgrade(w http.ResponseWriter, r *http.Request, i store.Instance)
|
|||||||
fail(w, 413, "quota exceeded or snapshot size unavailable")
|
fail(w, 413, "quota exceeded or snapshot size unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if upgradeCopy(ctx, root, "home", snapshot, snapshotBytes) != nil {
|
// Only publish a completed copy under a name the snapshot API recognizes.
|
||||||
fail(w, 500, "home snapshot failed")
|
staging := "." + snapshot + "-partial"
|
||||||
|
if _, err := a.s.DB.Exec("CREATE TABLE IF NOT EXISTS snapshot_copy_state (instance_id TEXT NOT NULL, id TEXT NOT NULL, ready INTEGER NOT NULL, PRIMARY KEY(instance_id,id))"); err != nil {
|
||||||
|
fail(w, 500, "snapshot completion metadata unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.s.DB.Exec("INSERT INTO snapshot_copy_state VALUES(?,?,0)", i.ID, snapshot); err != nil {
|
||||||
|
fail(w, 500, "snapshot completion metadata unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if copyErr := upgradeCopy(ctx, root, "home", staging, snapshotBytes); copyErr != nil {
|
||||||
|
if cleanupErr := root.RemoveAll(staging); cleanupErr != nil {
|
||||||
|
a.s.AddAudit(i.UID, "upgrade.snapshot.cleanup_failed", i.ID, staging)
|
||||||
|
fail(w, 500, "home snapshot failed: "+copyErr.Error()+"; partial snapshot cleanup required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(w, 500, "home snapshot failed: "+copyErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := root.Rename(staging, snapshot); err != nil {
|
||||||
|
if root.RemoveAll(staging) != nil {
|
||||||
|
a.s.AddAudit(i.UID, "upgrade.snapshot.cleanup_failed", i.ID, staging)
|
||||||
|
}
|
||||||
|
fail(w, 500, "home snapshot publication failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if upgradeSync(root) != nil {
|
||||||
|
fail(w, 500, "home snapshot publication sync failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE snapshot_copy_state SET ready=1 WHERE instance_id=? AND id=?", i.ID, snapshot); err != nil {
|
||||||
|
fail(w, 500, "snapshot completion metadata unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
snapReady = true
|
snapReady = true
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func TestUpgradeLifecycle(t *testing.T) {
|
|||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
t.Skip("requires chown")
|
t.Skip("requires chown")
|
||||||
}
|
}
|
||||||
for _, mode := range []string{"success", "legacy-layout", "shared-layout", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} {
|
for _, mode := range []string{"snapshot-fifo", "snapshot-dangling-link", "success", "legacy-layout", "shared-layout", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} {
|
||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
image := "sha256:" + strings.Repeat("b", 64)
|
image := "sha256:" + strings.Repeat("b", 64)
|
||||||
var a *API
|
var a *API
|
||||||
@@ -260,6 +260,16 @@ func TestUpgradeLifecycle(t *testing.T) {
|
|||||||
if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("original"), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("original"), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if mode == "snapshot-dangling-link" {
|
||||||
|
if err := os.Symlink("missing", filepath.Join(home, "dangling")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "snapshot-fifo" {
|
||||||
|
if err := syscall.Mkfifo(filepath.Join(home, "pipe"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", ImageID: "old-image", Version: "v1", Status: "exited", Created: time.Now()}
|
i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", ImageID: "old-image", Version: "v1", Status: "exited", Created: time.Now()}
|
||||||
if err := a.s.AddInstance(i); err != nil {
|
if err := a.s.AddInstance(i); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -297,6 +307,38 @@ func TestUpgradeLifecycle(t *testing.T) {
|
|||||||
if err := a.s.DB.QueryRow("SELECT phase,snapshot,failed_home FROM upgrade_journal WHERE instance_id='one'").Scan(&phase, &snapshot, &failed); err != nil {
|
if err := a.s.DB.QueryRow("SELECT phase,snapshot,failed_home FROM upgrade_journal WHERE instance_id='one'").Scan(&phase, &snapshot, &failed); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if mode == "snapshot-fifo" {
|
||||||
|
if w.Code != 500 || !strings.Contains(w.Body.String(), "home/pipe") || phase != "rolled_back" || got.ContainerID != "old" {
|
||||||
|
t.Fatalf("%d %s phase=%s", w.Code, w.Body.String(), phase)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(base)
|
||||||
|
if err != nil || len(entries) != 1 || entries[0].Name() != "home" {
|
||||||
|
t.Fatalf("partial snapshot leaked: %v %v", entries, err)
|
||||||
|
}
|
||||||
|
listed := configCall(a, got, "GET", "snapshots", "")
|
||||||
|
if listed.Code != 200 || strings.TrimSpace(listed.Body.String()) != "[]" {
|
||||||
|
t.Fatal(listed.Code, listed.Body.String())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == "snapshot-dangling-link" {
|
||||||
|
if w.Code != 200 || phase != "complete" || got.ContainerID != "new" {
|
||||||
|
t.Fatalf("unexpected result: %d %s phase=%s instance=%+v", w.Code, w.Body.String(), phase, got)
|
||||||
|
}
|
||||||
|
saved, err := os.ReadFile(filepath.Join(base, snapshot, ".config", "state"))
|
||||||
|
if err != nil || string(saved) != "original" {
|
||||||
|
t.Fatalf("copied data missing: %q %v", saved, err)
|
||||||
|
}
|
||||||
|
link, err := os.Readlink(filepath.Join(base, snapshot, "dangling"))
|
||||||
|
if err != nil || link != "missing" {
|
||||||
|
t.Fatalf("copied link missing: %q %v", link, err)
|
||||||
|
}
|
||||||
|
listed := configCall(a, got, "GET", "snapshots", "")
|
||||||
|
if listed.Code != 200 || !strings.Contains(listed.Body.String(), snapshot) {
|
||||||
|
t.Fatalf("snapshot not listed: %d %s", listed.Code, listed.Body.String())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
data, err := os.ReadFile(filepath.Join(home, ".config", "state"))
|
data, err := os.ReadFile(filepath.Join(home, ".config", "state"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -375,7 +417,7 @@ func TestUpgradeLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) {
|
func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) {
|
||||||
for _, kind := range []string{"symlink", "fifo", "oversize", "directory-symlink"} {
|
for _, kind := range []string{"fifo", "oversize"} {
|
||||||
t.Run(kind, func(t *testing.T) {
|
t.Run(kind, func(t *testing.T) {
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
if err := os.Mkdir(filepath.Join(base, "home"), 0700); err != nil {
|
if err := os.Mkdir(filepath.Join(base, "home"), 0700); err != nil {
|
||||||
@@ -384,14 +426,10 @@ func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) {
|
|||||||
p := filepath.Join(base, "home", "bad")
|
p := filepath.Join(base, "home", "bad")
|
||||||
var err error
|
var err error
|
||||||
switch kind {
|
switch kind {
|
||||||
case "symlink":
|
|
||||||
err = os.Symlink("/etc/passwd", p)
|
|
||||||
case "fifo":
|
case "fifo":
|
||||||
err = syscall.Mkfifo(p, 0600)
|
err = syscall.Mkfifo(p, 0600)
|
||||||
case "oversize":
|
case "oversize":
|
||||||
err = os.WriteFile(p, []byte("12345"), 0600)
|
err = os.WriteFile(p, []byte("12345"), 0600)
|
||||||
case "directory-symlink":
|
|
||||||
err = os.Symlink("/tmp", p)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -412,7 +450,7 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
|
|||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
t.Skip("requires chown")
|
t.Skip("requires chown")
|
||||||
}
|
}
|
||||||
for _, mode := range []string{"pnpm", "escape", "chain-escape", "loop", "dangling"} {
|
for _, mode := range []string{"pnpm", "absolute", "escape", "chain-escape", "loop", "dangling"} {
|
||||||
t.Run(mode, func(t *testing.T) {
|
t.Run(mode, func(t *testing.T) {
|
||||||
base := t.TempDir()
|
base := t.TempDir()
|
||||||
home := filepath.Join(base, "home")
|
home := filepath.Join(base, "home")
|
||||||
@@ -425,6 +463,8 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
link := ".pnpm/pkg/node_modules/pkg"
|
link := ".pnpm/pkg/node_modules/pkg"
|
||||||
switch mode {
|
switch mode {
|
||||||
|
case "absolute":
|
||||||
|
link = "/workspace/example"
|
||||||
case "escape":
|
case "escape":
|
||||||
link = "../../outside"
|
link = "../../outside"
|
||||||
case "chain-escape":
|
case "chain-escape":
|
||||||
@@ -446,12 +486,6 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer root.Close()
|
defer root.Close()
|
||||||
err = upgradeCopy(context.Background(), root, "home", "snapshot", 1024)
|
err = upgradeCopy(context.Background(), root, "home", "snapshot", 1024)
|
||||||
if mode != "pnpm" {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("unsafe link accepted")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -465,6 +499,13 @@ func TestUpgradeSnapshotRelativeLinks(t *testing.T) {
|
|||||||
if err := root.Rename("snapshot", "home"); err != nil {
|
if err := root.Rename("snapshot", "home"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if mode != "pnpm" {
|
||||||
|
got, err := root.Readlink("home/node_modules/pkg")
|
||||||
|
if err != nil || got != link {
|
||||||
|
t.Fatalf("restored link changed: %q %v", got, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
data, err := os.ReadFile(filepath.Join(home, "node_modules/pkg/index.js"))
|
data, err := os.ReadFile(filepath.Join(home, "node_modules/pkg/index.js"))
|
||||||
if err != nil || string(data) != "original" {
|
if err != nil || string(data) != "original" {
|
||||||
t.Fatalf("rollback link broken: %q %v", data, err)
|
t.Fatalf("rollback link broken: %q %v", data, err)
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" media="(prefers-color-scheme: light)" content="#152b36"/><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#101c22"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" content="#152b36"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||||
|
|||||||
+13
-11
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState, type FormEvent } from 'react';
|
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||||
import { Boxes, Container, FolderOpen, LogOut, Package, Settings, SquareTerminal, Users } from 'lucide-react';
|
import { Boxes, Container, FolderOpen, LogOut, Package, Settings, SquareTerminal, Users } from 'lucide-react';
|
||||||
import { api, ApiError, type User } from './api';
|
import { api, type User } from './api';
|
||||||
import { Alert, message, Panel, useAction } from './ui';
|
import { Alert, Panel, useAction } from './ui';
|
||||||
|
import { watchSession, type LoginState } from './sessionRecovery';
|
||||||
import { Instances } from './Instances';
|
import { Instances } from './Instances';
|
||||||
import { Images } from './Images';
|
import { Images } from './Images';
|
||||||
import { RuntimeSelector } from './RuntimeSelector';
|
import { RuntimeSelector } from './RuntimeSelector';
|
||||||
@@ -12,6 +13,7 @@ import { RuntimeWorkspace, useRuntimeInstances } from './RuntimeWorkspace';
|
|||||||
import { Button, buttonVariants } from './components/ui/button';
|
import { Button, buttonVariants } from './components/ui/button';
|
||||||
import { Input } from './components/ui/input';
|
import { Input } from './components/ui/input';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
|
import { ThemeSettings } from './ThemeSettings';
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{id: 'dsh', label: 'DSH', icon: SquareTerminal, description: '你的 DeepSeek Harness 工作台'},
|
{id: 'dsh', label: 'DSH', icon: SquareTerminal, description: '你的 DeepSeek Harness 工作台'},
|
||||||
@@ -25,17 +27,16 @@ const secondaryPages = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [user, setUser] = useState<User | null>(null), [loading, setLoading] = useState(true), [error, setError] = useState('');
|
const [{user, loading, error}, setSession] = useState<LoginState>({user: null, loading: true, error: ''});
|
||||||
|
const session = useRef<ReturnType<typeof watchSession> | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
const watcher = watchSession(() => api.me(), setSession, window, document);
|
||||||
api.me().then(s => { if (active) setUser(s.user); }).catch(e => { if (active && (!(e instanceof ApiError) || e.status !== 401)) setError(message(e)); }).finally(() => { if (active) setLoading(false); });
|
session.current = watcher;
|
||||||
const expired = (event: Event) => { setUser(null); setError(event instanceof CustomEvent && typeof event.detail === 'string' ? event.detail : '会话已过期,请重新登录。'); };
|
return () => { watcher.dispose(); session.current = null; };
|
||||||
window.addEventListener('session-expired', expired);
|
|
||||||
return () => { active = false; window.removeEventListener('session-expired', expired); };
|
|
||||||
}, []);
|
}, []);
|
||||||
if (loading) return <div className="grid min-h-dvh place-items-center text-muted-foreground" role="status">正在连接工作台…</div>;
|
if (loading) return <div className="grid min-h-dvh place-items-center text-muted-foreground" role="status">正在连接工作台…</div>;
|
||||||
if (!user) return <Login error={error} onLogin={u => { setUser(u); setError(''); }} />;
|
if (!user) return <Login error={error} onLogin={u => session.current?.login(u)} />;
|
||||||
return <Console key={user.id} user={user} onLogout={() => setUser(null)} onPasswordChanged={() => { setUser(null); setError('密码已修改,请使用新密码重新登录。'); }} />;
|
return <>{error && <div role="status" className="fixed inset-x-0 top-0 z-50 p-3"><Alert>{error}</Alert></div>}<Console key={user.id} user={user} onLogout={() => session.current?.stop()} onPasswordChanged={() => session.current?.stop('密码已修改,请使用新密码重新登录。')} /></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: () => void; onPasswordChanged: () => void}) {
|
function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: () => void; onPasswordChanged: () => void}) {
|
||||||
@@ -121,6 +122,7 @@ function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: ()
|
|||||||
{page === 'workspace' && <Workspace runtime={runtime} />}
|
{page === 'workspace' && <Workspace runtime={runtime} />}
|
||||||
{page === 'admin' && user.admin && <Admin />}
|
{page === 'admin' && user.admin && <Admin />}
|
||||||
{page === 'settings' && <>
|
{page === 'settings' && <>
|
||||||
|
<ThemeSettings />
|
||||||
<Panel title="DSH 实例" action={<Button variant="ghost" size="sm" disabled={runtime.loading} onClick={() => void runtime.refresh()}>刷新</Button>}>
|
<Panel title="DSH 实例" action={<Button variant="ghost" size="sm" disabled={runtime.loading} onClick={() => void runtime.refresh()}>刷新</Button>}>
|
||||||
{runtime.error && <Alert>{runtime.error}<Button variant="outline" size="sm" className="ml-2" disabled={runtime.loading} onClick={() => void runtime.refresh()}>重试</Button></Alert>}
|
{runtime.error && <Alert>{runtime.error}<Button variant="outline" size="sm" className="ml-2" disabled={runtime.loading} onClick={() => void runtime.refresh()}>重试</Button></Alert>}
|
||||||
{runtime.loading && <p role="status" className="m-0 mb-3 text-[13px] text-muted-foreground">正在刷新实例…</p>}
|
{runtime.loading && <p role="status" className="m-0 mb-3 text-[13px] text-muted-foreground">正在刷新实例…</p>}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, expect, it, vi } from 'vitest';
|
import { beforeEach, expect, it, vi } from 'vitest';
|
||||||
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
|
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0, resourceCursor: 0, snapshots: [] as any[], refreshStorage: vi.fn(async () => {})}));
|
||||||
vi.mock('react', async original => ({
|
vi.mock('react', async original => ({
|
||||||
...await original<typeof import('react')>(),
|
...await original<typeof import('react')>(),
|
||||||
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
|
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
|
||||||
@@ -8,7 +8,7 @@ vi.mock('react', async original => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('./ui', async original => ({
|
vi.mock('./ui', async original => ({
|
||||||
...await original<typeof import('./ui')>(),
|
...await original<typeof import('./ui')>(),
|
||||||
useResource: () => ({data: [{id: 'backup', created_at: '', size: 1}], loading: false, refresh: vi.fn(async () => {})}),
|
useResource: () => ({data: state.resourceCursor++ % 2 === 0 ? [{id: 'backup', created_at: '', size: 1}] : state.snapshots, loading: false, refresh: state.refreshStorage}),
|
||||||
}));
|
}));
|
||||||
import { BackupPanel } from './BackupPanel';
|
import { BackupPanel } from './BackupPanel';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
@@ -20,12 +20,13 @@ function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
|||||||
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||||
}
|
}
|
||||||
const refresh = vi.fn(async () => {});
|
const refresh = vi.fn(async () => {});
|
||||||
function render() {state.cursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh});}
|
const onRestored = vi.fn(async () => {});
|
||||||
|
function render() {state.cursor = 0; state.resourceCursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh, onRestored});}
|
||||||
function editor() {
|
function editor() {
|
||||||
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
|
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
|
||||||
return child.type(child.props);
|
return child.type(child.props);
|
||||||
}
|
}
|
||||||
beforeEach(() => {state.slots = []; state.cursor = 0; vi.restoreAllMocks(); refresh.mockClear();});
|
beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); refresh.mockClear(); onRestored.mockClear();});
|
||||||
it('retains restore password and shows failure inside the owning modal after confirmation', async () => {
|
it('retains restore password and shows failure inside the owning modal after confirmation', async () => {
|
||||||
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied'));
|
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied'));
|
||||||
nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick();
|
nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick();
|
||||||
@@ -38,4 +39,148 @@ it('retains restore password and shows failure inside the owning modal after con
|
|||||||
expect(nodes(modal, n => n.type === 'input')[0].props.value).toBe('original-password');
|
expect(nodes(modal, n => n.type === 'input')[0].props.value).toBe('original-password');
|
||||||
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/restore', 'POST', {backup_id: 'backup', password: 'original-password'});
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/restore', 'POST', {backup_id: 'backup', password: 'original-password'});
|
||||||
expect(refresh).not.toHaveBeenCalled();
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
function button(label: string) {
|
||||||
|
return nodes(render(), n => n.type === 'button' && (n.props.children === label || Array.isArray(n.props.children) && n.props.children.includes(label)))[0];
|
||||||
|
}
|
||||||
|
it('requires confirmation for configuration deletion and retains download', async () => {
|
||||||
|
const request = vi.spyOn(api, 'request').mockResolvedValue({});
|
||||||
|
expect(nodes(render(), n => n.type === 'a')[0].props.href).toBe('/api/instances/one/backups?download=backup');
|
||||||
|
button('删除配置包').props.onClick();
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(false);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
button('删除配置包').props.onClick();
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/backups?id=backup', 'DELETE');
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('shows snapshot bytes separately, deletes confirmed snapshots and refreshes after rejection', async () => {
|
||||||
|
state.snapshots = [{id: 'pre-restore-abc', size: 1048576, created_at: '', protected: false}];
|
||||||
|
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('instance recovery required'));
|
||||||
|
expect(JSON.stringify(render())).toContain('1.0 MiB');
|
||||||
|
button('删除快照').props.onClick();
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots?id=pre-restore-abc', 'DELETE');
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(JSON.stringify(render())).toContain('instance recovery required');
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('disables protected snapshots and does not report failed size scans as zero', () => {
|
||||||
|
state.snapshots = [{id: 'pre-restore-abc', size: 0, created_at: '', protected: true, protection_reason: 'recovery required', size_error: 'tree limit'}];
|
||||||
|
expect(button('删除快照').props.disabled).toBe(true);
|
||||||
|
const text = JSON.stringify(render());
|
||||||
|
expect(text).toContain('recovery required');
|
||||||
|
expect(text).toContain('统计不完整');
|
||||||
|
expect(text).toContain('tree limit');
|
||||||
|
});
|
||||||
|
it('refreshes storage after successful snapshot deletion', async () => {
|
||||||
|
state.snapshots = [{id: 'pre-restore-abc', size: 1048576, created_at: '', protected: false}];
|
||||||
|
const request = vi.spyOn(api, 'request').mockResolvedValue({ok: true});
|
||||||
|
button('删除快照').props.onClick();
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots?id=pre-restore-abc', 'DELETE');
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(JSON.stringify(render())).toContain('快照已删除');
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('manual storage refresh does not reset the profile', async () => {
|
||||||
|
button('刷新备份与占用').props.onClick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('only a successful restore invokes the profile reload callback', async () => {
|
||||||
|
vi.spyOn(api, 'request').mockResolvedValue({ok: true});
|
||||||
|
button('恢复').props.onClick();
|
||||||
|
nodes(editor(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()});
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(onRestored).toHaveBeenCalledOnce();
|
||||||
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = {id: 'pre-restore/a b', size: 1024, created_at: '', protected: false, restorable: true, restore_reason: '', source_image: 'image', source_version: '1.2.3'};
|
||||||
|
function snapshotEditor() {
|
||||||
|
const child = nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)[0];
|
||||||
|
return child.type(child.props);
|
||||||
|
}
|
||||||
|
const submitSnapshot = () => nodes(snapshotEditor(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()});
|
||||||
|
const snapshotPassword = () => nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'password')[0];
|
||||||
|
it('warns about plaintext keys before download, cancels safely, and navigates only after confirmation', async () => {
|
||||||
|
state.snapshots = [snapshot];
|
||||||
|
const assign = vi.fn();
|
||||||
|
vi.stubGlobal('window', {location: {assign}});
|
||||||
|
button('下载快照').props.onClick();
|
||||||
|
const confirmation = nodes(render(), n => n.type === ConfirmDialog)[0];
|
||||||
|
expect(confirmation.props.request.options.body).toContain('未加密');
|
||||||
|
expect(confirmation.props.request.options.body).toContain('密钥');
|
||||||
|
expect(confirmation.props.request.options.body).toContain('明文');
|
||||||
|
expect(assign).not.toHaveBeenCalled();
|
||||||
|
confirmation.props.onClose(false);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(assign).not.toHaveBeenCalled();
|
||||||
|
button('下载快照').props.onClick();
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(assign).toHaveBeenCalledExactlyOnceWith('/api/instances/one/snapshots?download=pre-restore%2Fa%20b');
|
||||||
|
});
|
||||||
|
it('restores a known snapshot with the current password and reloads the profile only on success', async () => {
|
||||||
|
state.snapshots = [snapshot];
|
||||||
|
const request = vi.spyOn(api, 'request').mockResolvedValue({ok: true});
|
||||||
|
expect(JSON.stringify(render())).toContain('1.2.3');
|
||||||
|
button('恢复快照').props.onClick();
|
||||||
|
await submitSnapshot();
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
expect(nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0);
|
||||||
|
const text = JSON.stringify(snapshotEditor());
|
||||||
|
for (const warning of ['覆盖完整 home', '失败回滚副本', '保留原始快照', '临时停止', '就绪验证', '不会自动降级']) expect(text).toContain(warning);
|
||||||
|
snapshotPassword().props.onChange({target: {value: 'current-password'}});
|
||||||
|
await submitSnapshot();
|
||||||
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots', 'POST', {id: snapshot.id, password: 'current-password', confirm: true, allow_unknown_version: false});
|
||||||
|
expect(onRestored).toHaveBeenCalledOnce();
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
it.each(['source_image', 'source_version'])('requires unknown compatibility acknowledgement when %s is missing, clears password and refreshes after failure', async missing => {
|
||||||
|
state.snapshots = [{...snapshot, [missing]: undefined}];
|
||||||
|
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('snapshot restore denied'));
|
||||||
|
button('恢复快照').props.onClick();
|
||||||
|
snapshotPassword().props.onChange({target: {value: 'current-password'}});
|
||||||
|
await submitSnapshot();
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
expect(nodes(snapshotEditor(), n => n.type === 'button' && n.props.children === '确认覆盖并恢复快照')[0].props.disabled).toBe(true);
|
||||||
|
nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'checkbox')[0].props.onChange({target: {checked: true}});
|
||||||
|
await submitSnapshot();
|
||||||
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots', 'POST', {id: snapshot.id, password: 'current-password', confirm: true, allow_unknown_version: true});
|
||||||
|
expect(snapshotPassword().props.value).toBe('');
|
||||||
|
expect(JSON.stringify(nodes(snapshotEditor(), n => n.type === DialogContent)[0])).toContain('snapshot restore denied');
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
it.each([{protected: true, restorable: true}, {protected: false, restorable: false}])('disables protected or incompatible snapshot restoration (%j)', flags => {
|
||||||
|
state.snapshots = [{...snapshot, ...flags, restore_reason: 'incompatible home', protection_reason: 'recovery required'}];
|
||||||
|
expect(button('恢复快照').props.disabled).toBe(true);
|
||||||
|
expect(JSON.stringify(render())).toContain('incompatible home');
|
||||||
|
expect(button('下载快照').props.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
it('cancels snapshot restoration without requests or profile reloads', () => {
|
||||||
|
state.snapshots = [snapshot];
|
||||||
|
const request = vi.spyOn(api, 'request');
|
||||||
|
button('恢复快照').props.onClick();
|
||||||
|
nodes(snapshotEditor(), n => n.type === 'button' && n.props.children === '取消')[0].props.onClick();
|
||||||
|
expect(nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)).toHaveLength(0);
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
+107
-7
@@ -1,6 +1,6 @@
|
|||||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
import { useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Download, HardDriveDownload, RotateCcw, Upload } from 'lucide-react';
|
import { Download, HardDriveDownload, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||||
import { api, instancePath, type Instance, type Backup } from './api';
|
import { api, instancePath, type Instance, type Backup, type Snapshot } from './api';
|
||||||
import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
|
import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
|
||||||
import { useConfirm } from './components/ConfirmDialog';
|
import { useConfirm } from './components/ConfirmDialog';
|
||||||
import { AlertBox } from './components/ui/alert';
|
import { AlertBox } from './components/ui/alert';
|
||||||
@@ -9,14 +9,47 @@ import { buttonVariants } from './components/ui/button';
|
|||||||
import { inputClass } from './components/ui/input';
|
import { inputClass } from './components/ui/input';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
|
|
||||||
export function BackupPanel({instance, refresh}: {instance: Instance; refresh: () => Promise<void>}) {
|
export function BackupPanel({instance, refresh, onRestored}: {instance: Instance; refresh: () => Promise<void>; onRestored: () => Promise<void>}) {
|
||||||
const base = instancePath(instance.id);
|
const base = instancePath(instance.id);
|
||||||
const backups = useResource(() => api.request<Backup[]>(base + '/backups'));
|
const backups = useResource(() => api.request<Backup[]>(base + '/backups'), base);
|
||||||
|
const snapshots = useResource(() => api.request<Snapshot[]>(base + '/snapshots'), base);
|
||||||
const action = useAction();
|
const action = useAction();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
|
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
|
||||||
const [restoreTarget, setRestoreTarget] = useState<Backup>();
|
const [restoreTarget, setRestoreTarget] = useState<Backup>();
|
||||||
|
const [snapshotTarget, setSnapshotTarget] = useState<Snapshot>();
|
||||||
|
async function refreshStorage() {
|
||||||
|
await Promise.all([backups.refresh(), snapshots.refresh(), refresh()]);
|
||||||
|
}
|
||||||
|
async function remove(item: Backup | Snapshot, snapshot: boolean) {
|
||||||
|
if (snapshot && (item as Snapshot).protected) return;
|
||||||
|
if (!await confirm.ask({
|
||||||
|
title: snapshot ? '删除完整 home 快照' : '删除配置备份', danger: true, confirmLabel: '永久删除',
|
||||||
|
body: `确认永久删除 ${item.id}?此操作不可撤销,不影响当前 home。${snapshot ? '删除后将无法使用此快照回滚。' : '请先下载需要保留的配置包;删除配置包不会清理完整快照。'}`,
|
||||||
|
})) return;
|
||||||
|
await action.run(async () => {
|
||||||
|
try { await api.request(base + (snapshot ? '/snapshots?id=' : '/backups?id=') + encodeURIComponent(item.id), 'DELETE'); }
|
||||||
|
finally { await refreshStorage(); }
|
||||||
|
}, snapshot ? '快照已删除,空间占用已刷新' : '配置备份已删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadSnapshot(snapshot: Snapshot) {
|
||||||
|
if (!await confirm.ask({
|
||||||
|
title: '下载完整 home 快照', danger: true, confirmLabel: '确认下载',
|
||||||
|
body: '下载的是未加密的 tar.gz 完整 home 归档,可能包含密钥、密码及其他明文敏感数据。请妥善保管,不要公开分享。',
|
||||||
|
})) return;
|
||||||
|
window.location.assign('/api' + base + '/snapshots?download=' + encodeURIComponent(snapshot.id));
|
||||||
|
}
|
||||||
|
async function restoreSnapshot(snapshot: Snapshot, password: string, allowUnknownVersion: boolean) {
|
||||||
|
if (snapshot.protected || !snapshot.restorable || !password || ((!snapshot.source_image || !snapshot.source_version) && !allowUnknownVersion)) return false;
|
||||||
|
return action.run(async () => {
|
||||||
|
try {
|
||||||
|
await api.request(base + '/snapshots', 'POST', {id: snapshot.id, password, confirm: true, allow_unknown_version: allowUnknownVersion});
|
||||||
|
await onRestored();
|
||||||
|
} finally { await refreshStorage(); }
|
||||||
|
}, '完整 home 快照恢复及就绪验证完成,已恢复原运行状态。');
|
||||||
|
}
|
||||||
|
|
||||||
function createBackup(e: FormEvent) {
|
function createBackup(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -44,7 +77,10 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
|
|||||||
title: '恢复配置备份', danger: true, confirmLabel: '确认恢复',
|
title: '恢复配置备份', danger: true, confirmLabel: '确认恢复',
|
||||||
body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。',
|
body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。',
|
||||||
})) return false;
|
})) return false;
|
||||||
return action.run(async () => { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await refresh(); }, '配置恢复及插件验证完成,已恢复原运行状态。');
|
return action.run(async () => {
|
||||||
|
try { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await onRestored(); }
|
||||||
|
finally { await snapshots.refresh(); }
|
||||||
|
}, '配置恢复及插件验证完成,已恢复原运行状态。');
|
||||||
}
|
}
|
||||||
|
|
||||||
return <section aria-label="备份与恢复" className="grid gap-5">
|
return <section aria-label="备份与恢复" className="grid gap-5">
|
||||||
@@ -70,7 +106,9 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!restoreTarget && action.feedback}
|
{!restoreTarget && !snapshotTarget && action.feedback}
|
||||||
|
<h3 className="m-0 text-sm font-semibold">配置备份(.dshbak)</h3>
|
||||||
|
<p className="m-0 text-xs text-muted-foreground">仅包含指定配置与资源,不包含完整 home 或依赖;下方大小为加密配置包大小。</p>
|
||||||
<ResourceState resource={backups} />
|
<ResourceState resource={backups} />
|
||||||
{backups.data?.length === 0 && <Empty>暂无配置备份</Empty>}
|
{backups.data?.length === 0 && <Empty>暂无配置备份</Empty>}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -79,20 +117,82 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
|
|||||||
<p className="m-0 truncate font-mono text-xs">{b.id}</p>
|
<p className="m-0 truncate font-mono text-xs">{b.id}</p>
|
||||||
<p className="m-0 mt-0.5 text-xs text-muted-foreground">{date(b.created_at)} · {bytes(b.size)}</p>
|
<p className="m-0 mt-0.5 text-xs text-muted-foreground">{date(b.created_at)} · {bytes(b.size)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<a className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}><Download aria-hidden />下载</a>
|
<a className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}><Download aria-hidden />下载</a>
|
||||||
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy} onClick={() => setRestoreTarget(b)}><RotateCcw aria-hidden />恢复</button>
|
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy} onClick={() => setRestoreTarget(b)}><RotateCcw aria-hidden />恢复</button>
|
||||||
|
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy} onClick={() => void remove(b, false)}><Trash2 aria-hidden />删除配置包</button>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section aria-label="完整 home 快照" className="grid gap-3 border-t border-border pt-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="m-0 text-sm font-semibold">完整 home 快照</h3>
|
||||||
|
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy || snapshots.loading || backups.loading} onClick={() => void refreshStorage()}>刷新备份与占用</button>
|
||||||
|
</div>
|
||||||
|
<AlertBox variant="info">升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。可下载未加密的 tar.gz 归档或恢复完整 home,请妥善保管其中的密钥与明文数据。删除不可撤销,待恢复实例的快照受保护。</AlertBox>
|
||||||
|
<ResourceState resource={snapshots} />
|
||||||
|
{snapshots.data && !snapshots.error && <p className="m-0 text-sm">快照占用(文件字节数):{snapshots.data.some(s => s.size_error) ? '统计不完整,请查看各项错误' : bytes(snapshots.data.reduce((total, s) => total + s.size, 0))}</p>}
|
||||||
|
{snapshots.data?.length === 0 && <Empty>暂无完整 home 快照</Empty>}
|
||||||
|
{snapshots.data?.map(s => <div key={s.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card px-3.5 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="m-0 break-all font-mono text-xs">{s.id}</p>
|
||||||
|
<p className="m-0 mt-1 text-xs text-muted-foreground">{date(s.created_at)} · {s.size_error ? '大小统计失败:' + s.size_error : bytes(s.size)}</p>
|
||||||
|
{s.protected && <p className="m-0 mt-1 text-xs text-destructive">受保护:{s.protection_reason || '实例需要恢复,请联系管理员'}</p>}
|
||||||
|
<p className="m-0 mt-1 text-xs text-muted-foreground">来源版本:{s.source_version || '未知'}{!s.source_image && ' · 来源镜像未知'}</p>
|
||||||
|
{s.restore_reason && <p className="m-0 mt-1 text-xs text-muted-foreground">{s.restore_reason}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy} onClick={() => void downloadSnapshot(s)}><Download aria-hidden />下载快照</button>
|
||||||
|
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy || s.protected || !s.restorable} onClick={() => setSnapshotTarget(s)}><RotateCcw aria-hidden />恢复快照</button>
|
||||||
|
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy || s.protected} onClick={() => void remove(s, true)}><Trash2 aria-hidden />删除快照</button>
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{restoreTarget && <RestoreDialog backup={restoreTarget} busy={action.busy} feedback={action.feedback} onClose={() => setRestoreTarget(undefined)} onSubmit={async password => {
|
{restoreTarget && <RestoreDialog backup={restoreTarget} busy={action.busy} feedback={action.feedback} onClose={() => setRestoreTarget(undefined)} onSubmit={async password => {
|
||||||
const ok = await restore(restoreTarget, password);
|
const ok = await restore(restoreTarget, password);
|
||||||
if (ok) setRestoreTarget(undefined);
|
if (ok) setRestoreTarget(undefined);
|
||||||
}} />}
|
}} />}
|
||||||
|
{snapshotTarget && <SnapshotRestoreDialog snapshot={snapshotTarget} busy={action.busy} feedback={action.feedback} onClose={() => setSnapshotTarget(undefined)} onSubmit={async (password, allowUnknownVersion) => {
|
||||||
|
const ok = await restoreSnapshot(snapshotTarget, password, allowUnknownVersion);
|
||||||
|
if (ok) setSnapshotTarget(undefined);
|
||||||
|
}} />}
|
||||||
</section>;
|
</section>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SnapshotRestoreDialog({snapshot, busy, feedback, onClose, onSubmit}: {snapshot: Snapshot; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string, allowUnknownVersion: boolean) => Promise<void>}) {
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [allowUnknownVersion, setAllowUnknownVersion] = useState(false);
|
||||||
|
const unknownVersion = !snapshot.source_image || !snapshot.source_version;
|
||||||
|
const disabled = busy || !password || snapshot.protected || !snapshot.restorable || (unknownVersion && !allowUnknownVersion);
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (disabled) return;
|
||||||
|
try { await onSubmit(password, unknownVersion && allowUnknownVersion); }
|
||||||
|
finally { setPassword(''); }
|
||||||
|
}
|
||||||
|
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
|
||||||
|
<DialogContent title={'恢复完整 home 快照 · ' + snapshot.id}>
|
||||||
|
<form className="grid gap-4" onSubmit={submit}>
|
||||||
|
{feedback}
|
||||||
|
<AlertBox variant="destructive">此操作将覆盖完整 home,包括配置、依赖及工作文件,未保存的编辑将丢失。服务器会先保留当前 home 作为失败回滚副本,并保留原始快照;按需临时停止实例,完成就绪验证后恢复原运行状态。不会自动降级或切换当前镜像。</AlertBox>
|
||||||
|
<p className="m-0 text-xs text-muted-foreground">来源版本:{snapshot.source_version || '未知'}</p>
|
||||||
|
{unknownVersion && <label className="flex items-start gap-2 text-[13px]">
|
||||||
|
<input type="checkbox" checked={allowUnknownVersion} onChange={e => setAllowUnknownVersion(e.target.checked)} disabled={busy} />
|
||||||
|
我理解来源镜像或版本未知,无法确认兼容性,仍要恢复。
|
||||||
|
</label>}
|
||||||
|
<label className="grid gap-1.5 text-[13px] font-medium">当前登录密码
|
||||||
|
<input className={inputClass} type="password" autoComplete="current-password" required value={password} onChange={e => setPassword(e.target.value)} disabled={busy} autoFocus /></label>
|
||||||
|
<DialogFooter className="border-t-0 pt-0">
|
||||||
|
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} disabled={busy} onClick={onClose}>取消</button>
|
||||||
|
<button className={cn(buttonVariants({variant: 'destructive'}))} disabled={disabled}>{busy ? '正在恢复…' : '确认覆盖并恢复快照'}</button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogRoot>;
|
||||||
|
}
|
||||||
|
|
||||||
function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise<void>}) {
|
function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise<void>}) {
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
|
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
slots: [] as any[], cursor: 0, resourceCursor: 0, resources: [] as any[],
|
||||||
|
}));
|
||||||
|
vi.mock('react', async original => ({
|
||||||
|
...await original<typeof import('react')>(),
|
||||||
|
useState(value: unknown) {
|
||||||
|
const slots = state.slots, i = state.cursor++;
|
||||||
|
if (!(i in slots)) slots[i] = value;
|
||||||
|
return [slots[i], (next: unknown) => {slots[i] = typeof next === 'function' ? next(slots[i]) : next;}];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./ui', async original => ({
|
||||||
|
...await original<typeof import('./ui')>(),
|
||||||
|
useResource: () => state.resources[state.resourceCursor++],
|
||||||
|
useAction: () => ({busy: false, feedback: null}),
|
||||||
|
}));
|
||||||
|
vi.mock('./components/ConfirmDialog', () => ({useConfirm: () => ({element: null})}));
|
||||||
|
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
|
||||||
|
|
||||||
|
import { Instances } from './Instances';
|
||||||
|
import { ProfileEditor } from './ProfileEditor';
|
||||||
|
import { BackupPanel } from './BackupPanel';
|
||||||
|
|
||||||
|
function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
||||||
|
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
|
||||||
|
if (!root || typeof root !== 'object') return [];
|
||||||
|
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preserves the unsaved Profile draft on backup refresh and reloads it only after restore', async () => {
|
||||||
|
const instance = {id: 'one', name: 'Test instance', status: 'stopped', created_at: '', version: 'test'};
|
||||||
|
const instances = {data: [instance], loading: false, refresh: vi.fn(async () => {})};
|
||||||
|
const usage = {data: {used_bytes: 1, quota_bytes: 100}, loading: false, refresh: vi.fn(async () => {})};
|
||||||
|
const profile = {
|
||||||
|
data: {content: 'name: original'}, loading: false,
|
||||||
|
refresh: vi.fn(async () => {profile.data = {content: 'name: restored'};}),
|
||||||
|
};
|
||||||
|
const parentSlots: any[] = [], detailSlots: any[] = [];
|
||||||
|
function renderParent() {
|
||||||
|
state.slots = parentSlots; state.cursor = 0; state.resourceCursor = 0;
|
||||||
|
state.resources = [instances, usage];
|
||||||
|
return Instances();
|
||||||
|
}
|
||||||
|
function renderDetail() {
|
||||||
|
const detail = nodes(renderParent(), n => typeof n.type === 'function' && n.props.instance?.id === instance.id)[0];
|
||||||
|
expect(detail).toBeDefined();
|
||||||
|
state.slots = detailSlots; state.cursor = 0; state.resourceCursor = 0;
|
||||||
|
state.resources = [{data: []}, profile, {data: undefined}, {data: undefined}];
|
||||||
|
return detail.type(detail.props);
|
||||||
|
}
|
||||||
|
const editor = (tree: any) => nodes(tree, n => n.type === ProfileEditor && n.props.onChange)[0];
|
||||||
|
const backup = (tree: any) => nodes(tree, n => n.type === BackupPanel)[0];
|
||||||
|
const save = (tree: any) => nodes(tree, n => n.type === 'button' && n.props.children === '保存并启动')[0];
|
||||||
|
|
||||||
|
nodes(renderParent(), n => n.type === 'button' && n.props.children === '管理')[0].props.onClick();
|
||||||
|
let tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: original');
|
||||||
|
expect(save(tree).props.disabled).toBe(true);
|
||||||
|
editor(tree).props.onChange('name: unsaved draft');
|
||||||
|
tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: unsaved draft');
|
||||||
|
expect(save(tree).props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await backup(tree).props.refresh();
|
||||||
|
expect(instances.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(usage.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(profile.refresh).not.toHaveBeenCalled();
|
||||||
|
tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: unsaved draft');
|
||||||
|
expect(save(tree).props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await backup(tree).props.onRestored();
|
||||||
|
expect(profile.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(instances.refresh).toHaveBeenCalledTimes(2);
|
||||||
|
expect(usage.refresh).toHaveBeenCalledTimes(2);
|
||||||
|
tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: restored');
|
||||||
|
expect(save(tree).props.disabled).toBe(true);
|
||||||
|
});
|
||||||
@@ -100,7 +100,7 @@ export function Instances() {
|
|||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
</Panel>
|
</Panel>
|
||||||
{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={instances.refresh} />}
|
{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={async () => { await Promise.all([instances.refresh(), usage.refresh()]); }} />}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ function InstanceDetail({instance, close, refresh}: {instance: Instance; close:
|
|||||||
</>}
|
</>}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<BackupPanel instance={instance} refresh={async () => { await profile.refresh(); setContent(undefined); await refresh(); }} />
|
<BackupPanel instance={instance} refresh={refresh} onRestored={async () => { await profile.refresh(); setContent(undefined); await refresh(); }} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="upgrade">
|
<TabsContent value="upgrade">
|
||||||
<form className="grid max-w-xl gap-4" onSubmit={async e => {
|
<form className="grid max-w-xl gap-4" onSubmit={async e => {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { ExternalLink, MoreVertical, RefreshCw, RotateCw, Settings2 } from 'lucide-react';
|
|
||||||
import { api, type Instance } from './api';
|
import { api, type Instance } from './api';
|
||||||
import { Alert, message, Status } from './ui';
|
import { Alert, message } from './ui';
|
||||||
import { RuntimeSelector } from './RuntimeSelector';
|
|
||||||
import { runtimeSelectionKey, selectRuntime } from './runtimeSelection';
|
import { runtimeSelectionKey, selectRuntime } from './runtimeSelection';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './components/ui/dropdown-menu';
|
|
||||||
import { buttonVariants } from './components/ui/button';
|
import { buttonVariants } from './components/ui/button';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
|
|
||||||
@@ -71,33 +68,11 @@ export function useRuntimeInstances(userId: string, page: string) {
|
|||||||
export function RuntimeWorkspace({ runtime, visible, manage, settings }: {
|
export function RuntimeWorkspace({ runtime, visible, manage, settings }: {
|
||||||
runtime: ReturnType<typeof useRuntimeInstances>; visible: boolean; manage: () => void; settings: () => void;
|
runtime: ReturnType<typeof useRuntimeInstances>; visible: boolean; manage: () => void; settings: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [revision, setRevision] = useState(0);
|
|
||||||
const [frameError, setFrameError] = useState(false);
|
const [frameError, setFrameError] = useState(false);
|
||||||
const selected = runtime.instances?.find(instance => instance.id === runtime.selectedId);
|
const selected = runtime.instances?.find(instance => instance.id === runtime.selectedId);
|
||||||
const url = selected ? '/dsh/' + encodeURIComponent(selected.id) + '/' : '';
|
const url = selected ? '/dsh/' + encodeURIComponent(selected.id) + '/' : '';
|
||||||
useEffect(() => { setFrameError(false); }, [selected?.id, revision]);
|
useEffect(() => { setFrameError(false); }, [selected?.id]);
|
||||||
const iconBtn = cn(buttonVariants({variant: 'ghost', size: 'icon'}), 'text-muted-foreground');
|
|
||||||
return <section className={cn('min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-card', visible ? 'flex' : 'hidden')} aria-label="DSH 工作台">
|
return <section className={cn('min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-card', visible ? 'flex' : 'hidden')} aria-label="DSH 工作台">
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b border-border px-3 py-2 sm:px-4">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<strong className="truncate text-[14px]">{selected?.name || 'DSH'}</strong>
|
|
||||||
{selected && <Status value={selected.status} />}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RuntimeSelector runtime={runtime} compact />
|
|
||||||
<button type="button" title="刷新状态" aria-label="刷新状态" disabled={runtime.loading} onClick={() => void runtime.refresh()} className={iconBtn}>
|
|
||||||
<RefreshCw className={runtime.loading ? 'animate-spin' : undefined} aria-hidden />
|
|
||||||
</button>
|
|
||||||
{selected?.status === 'running' && <DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className={iconBtn} aria-label="更多操作" title="更多操作"><MoreVertical aria-hidden /></DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onSelect={() => setRevision(value => value + 1)}><RotateCw aria-hidden />重新加载内嵌页面</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onSelect={() => window.open(url, '_blank', 'noopener,noreferrer')}><ExternalLink aria-hidden />新窗口打开</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onSelect={manage}><Settings2 aria-hidden />管理实例</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{runtime.error && <div className="shrink-0 px-3 pt-2"><Alert>{runtime.error} <button className={cn(buttonVariants({variant: 'outline', size: 'sm'}), 'ml-2')} onClick={() => void runtime.refresh()} disabled={runtime.loading}>重试</button></Alert></div>}
|
{runtime.error && <div className="shrink-0 px-3 pt-2"><Alert>{runtime.error} <button className={cn(buttonVariants({variant: 'outline', size: 'sm'}), 'ml-2')} onClick={() => void runtime.refresh()} disabled={runtime.loading}>重试</button></Alert></div>}
|
||||||
{!runtime.instances && runtime.loading && <div className="m-auto max-w-full overflow-auto p-10 text-center wrap-anywhere" role="status">正在加载实例…</div>}
|
{!runtime.instances && runtime.loading && <div className="m-auto max-w-full overflow-auto p-10 text-center wrap-anywhere" role="status">正在加载实例…</div>}
|
||||||
{!runtime.instances && !runtime.loading && <div className="m-auto max-w-full overflow-auto p-10 text-center wrap-anywhere">暂时无法加载实例,请重试。</div>}
|
{!runtime.instances && !runtime.loading && <div className="m-auto max-w-full overflow-auto p-10 text-center wrap-anywhere">暂时无法加载实例,请重试。</div>}
|
||||||
@@ -116,7 +91,7 @@ export function RuntimeWorkspace({ runtime, visible, manage, settings }: {
|
|||||||
</div>}
|
</div>}
|
||||||
{selected?.status === 'running' && <div className="flex min-h-0 flex-1 flex-col">
|
{selected?.status === 'running' && <div className="flex min-h-0 flex-1 flex-col">
|
||||||
{frameError && <div className="shrink-0 px-3 pt-2"><Alert>DSH 页面加载失败或登录已失效,请重新加载或在新窗口打开。</Alert></div>}
|
{frameError && <div className="shrink-0 px-3 pt-2"><Alert>DSH 页面加载失败或登录已失效,请重新加载或在新窗口打开。</Alert></div>}
|
||||||
<iframe className="block min-h-0 w-full flex-1 border-0 bg-card" key={selected.id + ':' + revision} src={url} title={'DSH · ' + selected.name} referrerPolicy="same-origin" onError={() => setFrameError(true)} onLoad={event => {
|
<iframe className="block min-h-0 w-full flex-1 border-0 bg-card" key={selected.id} src={url} title={'DSH · ' + selected.name} referrerPolicy="same-origin" onError={() => setFrameError(true)} onLoad={event => {
|
||||||
try {
|
try {
|
||||||
const doc = event.currentTarget.contentDocument;
|
const doc = event.currentTarget.contentDocument;
|
||||||
const path = event.currentTarget.contentWindow?.location.pathname;
|
const path = event.currentTarget.contentWindow?.location.pathname;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Panel } from './ui';
|
||||||
|
import { Button } from './components/ui/button';
|
||||||
|
import { setThemePreference, useThemePreference, type ThemePreference } from './nightMode';
|
||||||
|
|
||||||
|
const options: {value: ThemePreference; label: string}[] = [
|
||||||
|
{value: 'light', label: '日间'},
|
||||||
|
{value: 'dark', label: '夜间'},
|
||||||
|
{value: 'auto', label: '自动(跟随系统)'},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ThemeSettings() {
|
||||||
|
const preference = useThemePreference();
|
||||||
|
return <Panel title="外观">
|
||||||
|
<div role="group" aria-label="工作台主题" className="flex flex-wrap gap-2">
|
||||||
|
{options.map(option => <Button key={option.value} type="button"
|
||||||
|
variant={preference === option.value ? 'default' : 'outline'}
|
||||||
|
aria-pressed={preference === option.value} onClick={() => setThemePreference(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</Button>)}
|
||||||
|
</div>
|
||||||
|
<p className="mb-0 mt-3 text-[13px] text-muted-foreground">仅在此浏览器保存,适用于工作台、登录页和文件编辑器。自动模式随系统实时切换;内嵌 DSH 的主题独立管理,不受此设置影响。</p>
|
||||||
|
</Panel>;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface Entry { name: string; path?: string; is_dir: boolean; size: num
|
|||||||
export interface Usage { used_bytes: number; quota_bytes: number }
|
export interface Usage { used_bytes: number; quota_bytes: number }
|
||||||
export interface TextFile { content: string; version: string }
|
export interface TextFile { content: string; version: string }
|
||||||
export interface Backup { id: string; created_at: string; size: number }
|
export interface Backup { id: string; created_at: string; size: number }
|
||||||
|
export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string; restorable: boolean; restore_reason: string; source_version?: string; source_image?: string }
|
||||||
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
|
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
|
||||||
export function createApi(fetcher: typeof fetch = fetch) {
|
export function createApi(fetcher: typeof fetch = fetch) {
|
||||||
let csrf = '';
|
let csrf = '';
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { RuntimeRenew } from "./RuntimeRenew";
|
import { RuntimeRenew } from "./RuntimeRenew";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
import { initializeTheme } from "./nightMode";
|
||||||
|
const disposeTheme = initializeTheme();
|
||||||
|
if (import.meta.hot) import.meta.hot.dispose(disposeTheme);
|
||||||
createRoot(document.getElementById("root")!).render(<React.StrictMode>{window.location.hash.startsWith("#runtime-renew?") ? <RuntimeRenew /> : <App />}</React.StrictMode>);
|
createRoot(document.getElementById("root")!).render(<React.StrictMode>{window.location.hash.startsWith("#runtime-renew?") ? <RuntimeRenew /> : <App />}</React.StrictMode>);
|
||||||
|
|||||||
+86
-27
@@ -1,32 +1,91 @@
|
|||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
|
const hooks = vi.hoisted(() => ({cleanups: [] as (() => void)[], notify: vi.fn()}));
|
||||||
vi.mock('react', async importOriginal => ({
|
vi.mock('react', async original => ({
|
||||||
...await importOriginal<typeof import('react')>(),
|
...await original<typeof import('react')>(),
|
||||||
useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current:value}; },
|
useSyncExternalStore(subscribe: (fn: () => void) => () => void, snapshot: () => unknown) {
|
||||||
useState(value: unknown) { const i = hooks.cursor++; if (!(i in hooks.slots)) hooks.slots[i] = value; return [hooks.slots[i], (v: unknown) => { hooks.slots[i] = v; }]; },
|
hooks.cleanups.push(subscribe(hooks.notify));
|
||||||
useEffect(fn: () => (() => void) | undefined, deps: unknown[]) {
|
return snapshot();
|
||||||
const i = hooks.cursor++, previous = hooks.slots[i];
|
|
||||||
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.effects.push(() => { previous?.cleanup?.(); hooks.slots[i] = {deps, cleanup:fn()}; });
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
import { useNightMode } from './nightMode';
|
import { initializeTheme, setThemePreference, themeStorageKey, useNightMode, useThemePreference } from './nightMode';
|
||||||
|
import { ThemeSettings } from './ThemeSettings';
|
||||||
const media = {matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn()};
|
let cleanup: (() => void) | undefined;
|
||||||
const render = () => { hooks.cursor = 0; const result = useNightMode(); hooks.effects.splice(0).forEach(effect => effect()); return result; };
|
let media: EventTarget & {matches: boolean};
|
||||||
beforeEach(() => { hooks.slots = []; hooks.cursor = 0; hooks.effects = []; media.matches = false; vi.clearAllMocks(); vi.stubGlobal('window', {matchMedia: vi.fn(() => media)}); });
|
let win: EventTarget & {localStorage: {getItem: ReturnType<typeof vi.fn>; setItem: ReturnType<typeof vi.fn>}; matchMedia: ReturnType<typeof vi.fn>};
|
||||||
afterEach(() => { hooks.slots.forEach(s => s?.cleanup?.()); vi.unstubAllGlobals(); });
|
let dataset: {theme?: string};
|
||||||
|
let meta: {setAttribute: ReturnType<typeof vi.fn>};
|
||||||
it('reflects the current system color scheme', () => {
|
let saved: string | null;
|
||||||
media.matches = true;
|
beforeEach(() => {
|
||||||
expect(render()).toBe(true);
|
saved = null;
|
||||||
expect(media.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
media = Object.assign(new EventTarget(), {matches: false});
|
||||||
|
win = Object.assign(new EventTarget(), {localStorage: {getItem: vi.fn(() => saved), setItem: vi.fn((_key, value) => {saved = value;})}, matchMedia: vi.fn(() => media)});
|
||||||
|
dataset = {}; meta = {setAttribute: vi.fn()};
|
||||||
|
vi.stubGlobal('window', win);
|
||||||
|
vi.stubGlobal('document', {documentElement: {dataset}, querySelector: vi.fn(() => meta)});
|
||||||
|
hooks.notify.mockClear();
|
||||||
});
|
});
|
||||||
|
afterEach(() => {cleanup?.(); hooks.cleanups.splice(0).forEach(fn => fn()); vi.unstubAllGlobals();});
|
||||||
it('updates when the system scheme changes and unsubscribes on cleanup', () => {
|
function start() {cleanup = initializeTheme();}
|
||||||
expect(render()).toBe(false);
|
function os(dark: boolean) {media.matches = dark; media.dispatchEvent(new Event('change'));}
|
||||||
const listener = media.addEventListener.mock.calls[0][1] as (event: MediaQueryListEvent) => void;
|
function storage(value: string | null, key: string | null = themeStorageKey, area: unknown = win.localStorage) {
|
||||||
listener({matches: true} as MediaQueryListEvent);
|
win.dispatchEvent(Object.assign(new Event('storage'), {key, newValue: value, storageArea: area}));
|
||||||
expect(render()).toBe(true);
|
}
|
||||||
hooks.slots.forEach(s => s?.cleanup?.());
|
it('defaults to auto and applies OS changes to DOM, metadata and Monaco', () => {
|
||||||
expect(media.removeEventListener).toHaveBeenCalledWith('change', listener);
|
start(); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(false);
|
||||||
|
os(true); expect(useNightMode()).toBe(true); expect(hooks.notify).toHaveBeenCalled();
|
||||||
|
expect(dataset.theme).toBe('dark');
|
||||||
|
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#101c22');
|
||||||
|
os(false); expect(dataset.theme).toBe('light');
|
||||||
|
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#152b36');
|
||||||
|
});
|
||||||
|
it.each(['light', 'dark'] as const)('persists %s and overrides either OS scheme across initialization', value => {
|
||||||
|
start(); setThemePreference(value);
|
||||||
|
for (const system of [true, false]) {os(system); expect(useNightMode()).toBe(value === 'dark');}
|
||||||
|
expect(win.localStorage.setItem).toHaveBeenCalledWith(themeStorageKey, value);
|
||||||
|
cleanup!(); start(); expect(useThemePreference()).toBe(value); expect(dataset.theme).toBe(value);
|
||||||
|
setThemePreference('auto'); os(true); expect(useNightMode()).toBe(true); expect(saved).toBe('auto');
|
||||||
|
});
|
||||||
|
it.each([null, '', 'invalid', 'DARK', 'auto'])('treats stored %s as auto', value => {
|
||||||
|
saved = value; media.matches = true; start();
|
||||||
|
expect(useThemePreference()).toBe('auto'); expect(dataset.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
it('synchronizes tabs including removal, clear and invalid values without writing back', () => {
|
||||||
|
start(); storage('dark'); expect(useNightMode()).toBe(true);
|
||||||
|
storage('light'); expect(useThemePreference()).toBe('light'); os(true); expect(useNightMode()).toBe(false);
|
||||||
|
for (const value of [null, 'bad', 'auto']) {storage(value); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(true);}
|
||||||
|
storage('light'); storage(null, null); expect(useThemePreference()).toBe('auto');
|
||||||
|
storage('light', 'unrelated'); storage('light', themeStorageKey, {});
|
||||||
|
expect(useThemePreference()).toBe('auto'); expect(win.localStorage.setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('keeps manual choices usable with blocked storage', () => {
|
||||||
|
Object.defineProperty(win, 'localStorage', {get() {throw new Error('blocked');}});
|
||||||
|
start(); expect(useThemePreference()).toBe('auto');
|
||||||
|
setThemePreference('dark'); os(false); expect(useNightMode()).toBe(true);
|
||||||
|
setThemePreference('light'); os(true); expect(useNightMode()).toBe(false);
|
||||||
|
setThemePreference('auto'); expect(useNightMode()).toBe(true);
|
||||||
|
});
|
||||||
|
it('keeps a selection when storage writes fail', () => {
|
||||||
|
start(); win.localStorage.setItem.mockImplementation(() => {throw new Error('quota');});
|
||||||
|
setThemePreference('dark'); expect(dataset.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
it('initializes once and cleans up global listeners and hook subscriptions', () => {
|
||||||
|
const removeMedia = vi.spyOn(media, 'removeEventListener');
|
||||||
|
const removeWindow = vi.spyOn(win, 'removeEventListener');
|
||||||
|
start(); expect(initializeTheme()).toBe(cleanup); expect(win.matchMedia).toHaveBeenCalledOnce();
|
||||||
|
useNightMode(); hooks.cleanups.splice(0).forEach(fn => fn());
|
||||||
|
hooks.notify.mockClear(); setThemePreference('light'); expect(hooks.notify).not.toHaveBeenCalled();
|
||||||
|
cleanup!(); cleanup = undefined;
|
||||||
|
expect(removeMedia).toHaveBeenCalledWith('change', expect.any(Function));
|
||||||
|
expect(removeWindow).toHaveBeenCalledWith('storage', expect.any(Function));
|
||||||
|
os(true); storage('dark'); expect(dataset.theme).toBe('light');
|
||||||
|
});
|
||||||
|
it('settings buttons expose one pressed choice and explain DSH independence', () => {
|
||||||
|
start(); const buttons = () => ThemeSettings().props.children[0].props.children;
|
||||||
|
expect(buttons().map((b: any) => b.props['aria-pressed'])).toEqual([false, false, true]);
|
||||||
|
for (const [index, value] of ['light', 'dark', 'auto'].entries()) {
|
||||||
|
buttons()[index].props.onClick(); expect(useThemePreference()).toBe(value);
|
||||||
|
expect(buttons().filter((b: any) => b.props['aria-pressed'])).toHaveLength(1);
|
||||||
|
expect(buttons()[index].props['aria-pressed']).toBe(true);
|
||||||
|
}
|
||||||
|
expect(JSON.stringify(ThemeSettings())).toContain('内嵌 DSH 的主题独立管理');
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-12
@@ -1,16 +1,58 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
const query = () => window.matchMedia('(prefers-color-scheme: dark)');
|
export type ThemePreference = 'light' | 'dark' | 'auto';
|
||||||
|
export const themeStorageKey = 'dsh.console.theme';
|
||||||
|
let preference: ThemePreference = 'auto';
|
||||||
|
let dark = false;
|
||||||
|
let media: MediaQueryList | undefined;
|
||||||
|
let dispose: (() => void) | undefined;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const normalize = (value: string | null): ThemePreference => value === 'light' || value === 'dark' ? value : 'auto';
|
||||||
|
|
||||||
/** Tracks the OS dark-scheme preference, matching the automatic night mode of embedded DSH. */
|
function update() {
|
||||||
|
dark = preference === 'dark' || (preference === 'auto' && !!media?.matches);
|
||||||
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||||
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#101c22' : '#152b36');
|
||||||
|
listeners.forEach(listener => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeTheme() {
|
||||||
|
if (dispose) return dispose;
|
||||||
|
try { preference = normalize(window.localStorage.getItem(themeStorageKey)); } catch { preference = 'auto'; }
|
||||||
|
media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const storage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== themeStorageKey && event.key !== null) return;
|
||||||
|
try { if (event.storageArea !== window.localStorage) return; } catch { return; }
|
||||||
|
preference = normalize(event.key === null ? null : event.newValue);
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
media.addEventListener('change', update);
|
||||||
|
window.addEventListener('storage', storage);
|
||||||
|
update();
|
||||||
|
const currentMedia = media;
|
||||||
|
dispose = () => {
|
||||||
|
currentMedia.removeEventListener('change', update);
|
||||||
|
window.removeEventListener('storage', storage);
|
||||||
|
dispose = undefined;
|
||||||
|
};
|
||||||
|
return dispose;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThemePreference(value: ThemePreference) {
|
||||||
|
preference = value;
|
||||||
|
try { window.localStorage.setItem(themeStorageKey, value); } catch { /* Keep the selection for this page when storage is blocked. */ }
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener: () => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => { listeners.delete(listener); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemePreference() {
|
||||||
|
return useSyncExternalStore(subscribe, () => preference);
|
||||||
|
}
|
||||||
|
|
||||||
export function useNightMode() {
|
export function useNightMode() {
|
||||||
const [dark, setDark] = useState(query().matches);
|
return useSyncExternalStore(subscribe, () => dark);
|
||||||
useEffect(() => {
|
|
||||||
const media = query();
|
|
||||||
const update = (event: MediaQueryListEvent) => setDark(event.matches);
|
|
||||||
setDark(media.matches);
|
|
||||||
media.addEventListener('change', update);
|
|
||||||
return () => media.removeEventListener('change', update);
|
|
||||||
}, []);
|
|
||||||
return dark;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { expect, it, vi } from 'vitest';
|
||||||
|
import { ApiError, type Session } from './api';
|
||||||
|
import { watchSession } from './sessionRecovery';
|
||||||
|
|
||||||
|
const session: Session = {user: {id: 'u', username: 'member', admin: false, quota_bytes: 0}, csrf_token: 'csrf'};
|
||||||
|
function setup(me = vi.fn<() => Promise<Session>>().mockResolvedValue(session)) {
|
||||||
|
const page = new EventTarget();
|
||||||
|
const document = Object.assign(new EventTarget(), {hidden: false});
|
||||||
|
const update = vi.fn();
|
||||||
|
const watcher = watchSession(me, update, page, document);
|
||||||
|
return {me, page, document, update, watcher, state: () => update.mock.calls.at(-1)?.[0]};
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(['online', 'pageshow', 'focus', 'visibilitychange'])('restores an expired login on %s without restarting', async event => {
|
||||||
|
const s = setup(); await s.watcher.check();
|
||||||
|
s.me.mockRejectedValueOnce(new ApiError(401, 'expired'));
|
||||||
|
s.page.dispatchEvent(new Event('session-expired')); await s.watcher.check();
|
||||||
|
expect(s.state().user).toBeNull();
|
||||||
|
(event === 'visibilitychange' ? s.document : s.page).dispatchEvent(new Event(event));
|
||||||
|
await s.watcher.check();
|
||||||
|
expect(s.state()).toEqual({user: session.user, loading: false, error: ''});
|
||||||
|
s.watcher.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rechecks a stale expiration event before discarding the user', async () => {
|
||||||
|
const s = setup(); await s.watcher.check();
|
||||||
|
s.page.dispatchEvent(new Event('session-expired')); await s.watcher.check();
|
||||||
|
expect(s.me).toHaveBeenCalledTimes(2);
|
||||||
|
expect(s.update.mock.calls.every(([state]) => state.user === session.user)).toBe(true);
|
||||||
|
s.watcher.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([0, 500])('keeps the logged-in user on transient failure %s, then clears the warning', async status => {
|
||||||
|
const s = setup(); await s.watcher.check();
|
||||||
|
s.me.mockRejectedValueOnce(new ApiError(status, 'offline'));
|
||||||
|
s.page.dispatchEvent(new Event('session-expired')); await s.watcher.check();
|
||||||
|
expect(s.state().user).toEqual(session.user);
|
||||||
|
expect(s.state().error).toContain('暂时无法确认');
|
||||||
|
s.page.dispatchEvent(new Event('online')); await s.watcher.check();
|
||||||
|
expect(s.state().error).toBe('');
|
||||||
|
s.watcher.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers from an offline cold start and keeps anonymous startup quiet', async () => {
|
||||||
|
const s = setup(vi.fn<() => Promise<Session>>().mockRejectedValueOnce(new ApiError(0, 'offline')).mockResolvedValue(session));
|
||||||
|
await s.watcher.check();
|
||||||
|
expect(s.state().loading).toBe(false);
|
||||||
|
s.page.dispatchEvent(new Event('online')); await s.watcher.check();
|
||||||
|
expect(s.state().user).toEqual(session.user); s.watcher.dispose();
|
||||||
|
const anonymous = setup(vi.fn<() => Promise<Session>>().mockRejectedValue(new ApiError(401, 'anonymous')));
|
||||||
|
await anonymous.watcher.check();
|
||||||
|
anonymous.page.dispatchEvent(new Event('focus')); await anonymous.watcher.check();
|
||||||
|
expect(anonymous.state().error).toBe(''); anonymous.watcher.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['logout', 'password', 'login', 'dispose'])('ignores old responses after %s', async action => {
|
||||||
|
let resolve!: (value: Session) => void;
|
||||||
|
const s = setup(vi.fn<() => Promise<Session>>().mockReturnValue(new Promise(r => {resolve = r;})));
|
||||||
|
const pending = s.watcher.check();
|
||||||
|
const nextUser = {...session.user, id: 'new'};
|
||||||
|
if (action === 'logout') s.watcher.stop();
|
||||||
|
if (action === 'password') s.page.dispatchEvent(Object.assign(new Event('session-expired'), {detail: '密码已重设'}));
|
||||||
|
if (action === 'login') s.watcher.login(nextUser);
|
||||||
|
if (action === 'dispose') s.watcher.dispose();
|
||||||
|
const calls = s.update.mock.calls.length;
|
||||||
|
resolve(session); await pending;
|
||||||
|
expect(s.update).toHaveBeenCalledTimes(calls);
|
||||||
|
if (action !== 'dispose') expect(s.state().user).toEqual(action === 'login' ? nextUser : null);
|
||||||
|
if (action !== 'login') {
|
||||||
|
s.page.dispatchEvent(new Event('online'));
|
||||||
|
s.page.dispatchEvent(new Event('session-expired'));
|
||||||
|
expect(s.me).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
s.watcher.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces simultaneous resume events and skips hidden pages', async () => {
|
||||||
|
const s = setup();
|
||||||
|
s.page.dispatchEvent(new Event('online'));
|
||||||
|
s.document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
await s.watcher.check();
|
||||||
|
expect(s.me).toHaveBeenCalledTimes(1);
|
||||||
|
s.document.hidden = true; s.page.dispatchEvent(new Event('focus'));
|
||||||
|
expect(s.me).toHaveBeenCalledTimes(1);
|
||||||
|
s.watcher.dispose();
|
||||||
|
s.document.hidden = false; s.document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
expect(s.me).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ApiError, type Session, type User } from './api';
|
||||||
|
|
||||||
|
export interface LoginState { user: User | null; loading: boolean; error: string }
|
||||||
|
|
||||||
|
// Own the lifecycle of session checks so stale responses cannot undo an explicit
|
||||||
|
// logout, password change, or a newer login. Network failures are not logouts.
|
||||||
|
export function watchSession(
|
||||||
|
me: () => Promise<Session>,
|
||||||
|
update: (state: LoginState) => void,
|
||||||
|
page: EventTarget,
|
||||||
|
document: EventTarget & { hidden: boolean },
|
||||||
|
) {
|
||||||
|
let state: LoginState = { user: null, loading: true, error: '' };
|
||||||
|
let active = true, enabled = true, generation = 0;
|
||||||
|
let hadSession = false;
|
||||||
|
let pending: Promise<void> | undefined;
|
||||||
|
const publish = (next: LoginState) => { state = next; if (active) update(state); };
|
||||||
|
const invalidate = () => { generation++; pending = undefined; };
|
||||||
|
const check = () => {
|
||||||
|
if (!active || !enabled) return Promise.resolve();
|
||||||
|
if (pending) return pending;
|
||||||
|
const version = generation;
|
||||||
|
pending = (async () => {
|
||||||
|
try {
|
||||||
|
const session = await me();
|
||||||
|
if (active && version === generation) {
|
||||||
|
hadSession = true;
|
||||||
|
publish({ user: session.user, loading: false, error: '' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!active || version !== generation) return;
|
||||||
|
const expired = error instanceof ApiError && error.status === 401;
|
||||||
|
publish({ user: expired ? null : state.user, loading: false, error: expired
|
||||||
|
? (hadSession ? '会话已过期,请重新登录。' : '')
|
||||||
|
: '暂时无法确认登录状态,请检查网络;恢复连接后将自动重试。' });
|
||||||
|
} finally {
|
||||||
|
if (version === generation) pending = undefined;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
const stop = (error = '') => {
|
||||||
|
enabled = false; invalidate();
|
||||||
|
publish({ user: null, loading: false, error });
|
||||||
|
};
|
||||||
|
const expired = (event: Event) => {
|
||||||
|
// Password reset is an explicit invalidation, not a transient request error.
|
||||||
|
const detail = 'detail' in event ? event.detail : undefined;
|
||||||
|
if (typeof detail === 'string') stop(detail);
|
||||||
|
else void check();
|
||||||
|
};
|
||||||
|
const resume = () => { if (!document.hidden) void check(); };
|
||||||
|
page.addEventListener('session-expired', expired);
|
||||||
|
page.addEventListener('online', resume);
|
||||||
|
page.addEventListener('pageshow', resume);
|
||||||
|
page.addEventListener('focus', resume);
|
||||||
|
document.addEventListener('visibilitychange', resume);
|
||||||
|
void check();
|
||||||
|
return {
|
||||||
|
check, stop,
|
||||||
|
login(user: User) {
|
||||||
|
invalidate(); enabled = true; hadSession = true;
|
||||||
|
publish({ user, loading: false, error: '' });
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
active = false; invalidate();
|
||||||
|
page.removeEventListener('session-expired', expired);
|
||||||
|
page.removeEventListener('online', resume);
|
||||||
|
page.removeEventListener('pageshow', resume);
|
||||||
|
page.removeEventListener('focus', resume);
|
||||||
|
document.removeEventListener('visibilitychange', resume);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-6
@@ -51,15 +51,11 @@
|
|||||||
--touch-min: 2.75rem;
|
--touch-min: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Automatic night mode: follow the OS color scheme, same as embedded DSH.
|
|
||||||
Dark values override the @theme variables above; utilities keep working
|
|
||||||
because they reference the variables at computed-value time. */
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
:root[data-theme="dark"] {
|
||||||
:root {
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--color-background: #101c22;
|
--color-background: #101c22;
|
||||||
--color-foreground: #d9e5e9;
|
--color-foreground: #d9e5e9;
|
||||||
@@ -90,7 +86,6 @@
|
|||||||
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.35), 0 1px 6px rgb(0 0 0 / 0.3);
|
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.35), 0 1px 6px rgb(0 0 0 / 0.3);
|
||||||
--shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55);
|
--shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
Reference in New Issue
Block a user