6 Commits
Author SHA1 Message Date
m1saka 264a5e664a refactor(web): remove DSH workspace status bar duplicated in settings 2026-10-22 16:55:03 +08:00
m1saka ed72985e7b fix: preserve snapshot symlinks and guard incomplete upgrade copies 2026-10-18 10:12:53 +08:00
m1saka 1c244e135f fix: recover login state after network interruptions 2026-10-17 22:44:10 +08:00
m1saka f77f3d77c0 feat: add full snapshot downloads and safe manual restore 2026-10-17 01:36:21 +08:00
m1saka c6615faa49 fix: extend proxy timeout for Office rendering 2026-10-17 00:52:20 +08:00
m1saka 602917698a feat: add safe backup management and theme preferences
Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
2026-10-17 00:36:21 +08:00
31 changed files with 3104 additions and 160 deletions
+28
View File
@@ -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
View File
@@ -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`。
+315
View File
@@ -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})
}
+413
View File
@@ -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
View File
@@ -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")
+101
View File
@@ -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)
}
})
}
+1 -1
View File
@@ -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)
} }
+2 -1
View File
@@ -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
View File
@@ -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
+167
View File
@@ -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)
}
})
}
}
+230
View File
@@ -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)
}
+297
View File
@@ -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)
}
}
+394
View File
@@ -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})
}
+267
View File
@@ -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
View File
@@ -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
+54 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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>}
+149 -4
View File
@@ -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
View File
@@ -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(); }}>
+81
View File
@@ -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);
});
+2 -2
View File
@@ -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 => {
+3 -28
View File
@@ -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;
+23
View File
@@ -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>;
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
} }
+88
View File
@@ -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);
});
+74
View File
@@ -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
View File
@@ -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;
@@ -91,7 +87,6 @@
--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 {
* { * {