diff --git a/API.md b/API.md index 920298c..f87647e 100644 --- a/API.md +++ b/API.md @@ -8,8 +8,21 @@ - GET /api/runtime-session.js:无需登录,返回 JavaScript、Cache-Control: no-store,用于运行时页面续期。 ## 配置 + - UPLOAD_ALLOWED_EXTENSIONS:可选逗号分隔扩展名白名单(例如 .txt,.md);空表示不限制。大小写不敏感,非内容/恶意软件检测。 +## 实例备份与快照管理 + +- `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=` 删除选定快照。先持久化 `snapshot.delete.started`,删除成功后记录 `snapshot.delete.completed`,删除失败则记录 `snapshot.delete.failed`;target 为实例 ID,detail 为快照 ID。初始审计失败时不会删除,终态审计失败返回500并保留 started 记录。不提供快照下载。 + +仅识别实例目录直属的 `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=&confirm=<相同完整id> 二次核验后非强制删除,保留bind数据及网络。仅接受本项目身份/挂载匹配、已停止、无实例或升级引用的容器;普通用户403,确认不匹配400,状态不安全409。 diff --git a/OPERATIONS.md b/OPERATIONS.md index 20bb5ee..493026c 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -118,6 +118,14 @@ 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 ` 和 `scripts/test-mobile-image.mjs ` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。 +## 手动备份空间管理 + +实例备份包和历史完整工作区快照必须由用户显式删除,不执行自动清理。快照列表与删除接口见 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(列表为空)。 + ## 会话有效期 在 `.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`。 diff --git a/internal/api/backup_management.go b/internal/api/backup_management.go new file mode 100644 index 0000000..786f24f --- /dev/null +++ b/internal/api/backup_management.go @@ -0,0 +1,303 @@ +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"` +} + +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.Size, err = snapshotBytes(r.Context(), root, entry.Name()) + if err != nil { + s.Size = -1 + s.SizeError = "snapshot size unavailable: " + err.Error() + } + 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}) +} diff --git a/internal/api/backup_management_test.go b/internal/api/backup_management_test.go new file mode 100644 index 0000000..8722573 --- /dev/null +++ b/internal/api/backup_management_test.go @@ -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()) + } +} diff --git a/internal/api/config.go b/internal/api/config.go index 2a01b85..62ccffe 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -23,6 +23,10 @@ var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$") var configBodySlots = make(chan struct{}, 4) func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) { + if action == "snapshots" || (action == "backups" && r.Method == http.MethodDelete) { + a.backupManagement(w, r, i, action) + return + } controller := http.NewResponseController(w) if r.Method != http.MethodGet || action != "backups" || r.URL.Query().Get("download") == "" { var flush func() diff --git a/internal/api/download_filename_test.go b/internal/api/download_filename_test.go new file mode 100644 index 0000000..4ad0f70 --- /dev/null +++ b/internal/api/download_filename_test.go @@ -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) + } + }) +} diff --git a/internal/api/files.go b/internal/api/files.go index 3bdb385..c328f39 100644 --- a/internal/api/files.go +++ b/internal/api/files.go @@ -781,7 +781,7 @@ func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root) fail(w, 400, "invalid file") 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) } diff --git a/internal/api/runtime.go b/internal/api/runtime.go index 3cb5b21..a3a0809 100644 --- a/internal/api/runtime.go +++ b/internal/api/runtime.go @@ -340,7 +340,7 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) { case "profile-preview": a.profilePreview(w, r, i) return - case "profile", "backups", "restore": + case "profile", "backups", "restore", "snapshots": a.config(w, r, i, action) return case "upgrade": diff --git a/web/index.html b/web/index.html index 7e46cf3..0477518 100644 --- a/web/index.html +++ b/web/index.html @@ -1,2 +1,2 @@ -DSH · 工作台
+DSH · 工作台
diff --git a/web/src/App.tsx b/web/src/App.tsx index a286d08..9c49851 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import { RuntimeWorkspace, useRuntimeInstances } from './RuntimeWorkspace'; import { Button, buttonVariants } from './components/ui/button'; import { Input } from './components/ui/input'; import { cn } from './lib/utils'; +import { ThemeSettings } from './ThemeSettings'; const pages = [ {id: 'dsh', label: 'DSH', icon: SquareTerminal, description: '你的 DeepSeek Harness 工作台'}, @@ -121,6 +122,7 @@ function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: () {page === 'workspace' && } {page === 'admin' && user.admin && } {page === 'settings' && <> + void runtime.refresh()}>刷新}> {runtime.error && {runtime.error}} {runtime.loading &&

正在刷新实例…

} diff --git a/web/src/BackupPanel.test.tsx b/web/src/BackupPanel.test.tsx index f09e09a..ba2c685 100644 --- a/web/src/BackupPanel.test.tsx +++ b/web/src/BackupPanel.test.tsx @@ -1,5 +1,5 @@ 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 => ({ ...await original(), 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 => ({ ...await original(), - 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 { 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)]; } 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() { const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0]; 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(); refresh.mockClear(); onRestored.mockClear();}); 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')); nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick(); @@ -38,4 +39,72 @@ 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(request).toHaveBeenCalledExactlyOnceWith('/instances/one/restore', 'POST', {backup_id: 'backup', password: 'original-password'}); 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(); }); diff --git a/web/src/BackupPanel.tsx b/web/src/BackupPanel.tsx index ffa2ac1..80f83ca 100644 --- a/web/src/BackupPanel.tsx +++ b/web/src/BackupPanel.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent, type ReactNode } from 'react'; -import { Download, HardDriveDownload, RotateCcw, Upload } from 'lucide-react'; -import { api, instancePath, type Instance, type Backup } from './api'; +import { Download, HardDriveDownload, RotateCcw, Trash2, Upload } from 'lucide-react'; +import { api, instancePath, type Instance, type Backup, type Snapshot } from './api'; import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui'; import { useConfirm } from './components/ConfirmDialog'; import { AlertBox } from './components/ui/alert'; @@ -9,14 +9,29 @@ import { buttonVariants } from './components/ui/button'; import { inputClass } from './components/ui/input'; import { cn } from './lib/utils'; -export function BackupPanel({instance, refresh}: {instance: Instance; refresh: () => Promise}) { +export function BackupPanel({instance, refresh, onRestored}: {instance: Instance; refresh: () => Promise; onRestored: () => Promise}) { const base = instancePath(instance.id); - const backups = useResource(() => api.request(base + '/backups')); + const backups = useResource(() => api.request(base + '/backups'), base); + const snapshots = useResource(() => api.request(base + '/snapshots'), base); const action = useAction(); const confirm = useConfirm(); const [password, setPassword] = useState(''); const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState(); const [restoreTarget, setRestoreTarget] = useState(); + 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 ? '快照已删除,空间占用已刷新' : '配置备份已删除'); + } function createBackup(e: FormEvent) { e.preventDefault(); @@ -44,7 +59,10 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: ( title: '恢复配置备份', danger: true, confirmLabel: '确认恢复', body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。', })) 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
@@ -71,6 +89,8 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: ( {!restoreTarget && action.feedback} +

配置备份(.dshbak)

+

仅包含指定配置与资源,不包含完整 home 或依赖;下方大小为加密配置包大小。

{backups.data?.length === 0 && 暂无配置备份}
@@ -79,13 +99,33 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (

{b.id}

{date(b.created_at)} · {bytes(b.size)}

- )} +
+
+

完整 home 快照

+ +
+ 升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。快照暂不支持网页下载,需要导出时请联系管理员。删除不可撤销,待恢复实例的快照受保护。 + + {snapshots.data && !snapshots.error &&

快照占用(文件字节数):{snapshots.data.some(s => s.size_error) ? '统计不完整,请查看各项错误' : bytes(snapshots.data.reduce((total, s) => total + s.size, 0))}

} + {snapshots.data?.length === 0 && 暂无完整 home 快照} + {snapshots.data?.map(s =>
+
+

{s.id}

+

{date(s.created_at)} · {s.size_error ? '大小统计失败:' + s.size_error : bytes(s.size)}

+ {s.protected &&

受保护:{s.protection_reason || '实例需要恢复,请联系管理员'}

} +
+ +
)} +
+ {restoreTarget && setRestoreTarget(undefined)} onSubmit={async password => { const ok = await restore(restoreTarget, password); if (ok) setRestoreTarget(undefined); diff --git a/web/src/Instances.test.tsx b/web/src/Instances.test.tsx new file mode 100644 index 0000000..89c6aa8 --- /dev/null +++ b/web/src/Instances.test.tsx @@ -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(), + 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(), + 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); +}); diff --git a/web/src/Instances.tsx b/web/src/Instances.tsx index 7944e25..e27e3f0 100644 --- a/web/src/Instances.tsx +++ b/web/src/Instances.tsx @@ -100,7 +100,7 @@ export function Instances() { } - {selected && i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={instances.refresh} />} + {selected && 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: } - { await profile.refresh(); setContent(undefined); await refresh(); }} /> + { await profile.refresh(); setContent(undefined); await refresh(); }} />
{ diff --git a/web/src/ThemeSettings.tsx b/web/src/ThemeSettings.tsx new file mode 100644 index 0000000..5dd9c10 --- /dev/null +++ b/web/src/ThemeSettings.tsx @@ -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 +
+ {options.map(option => )} +
+

仅在此浏览器保存,适用于工作台、登录页和文件编辑器。自动模式随系统实时切换;内嵌 DSH 的主题独立管理,不受此设置影响。

+
; +} diff --git a/web/src/api.ts b/web/src/api.ts index 610fdb1..9ea6951 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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 TextFile { content: string; version: string } 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 } export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string } export function createApi(fetcher: typeof fetch = fetch) { let csrf = ''; diff --git a/web/src/main.tsx b/web/src/main.tsx index ee15657..4992a60 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -3,4 +3,7 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import { RuntimeRenew } from "./RuntimeRenew"; import "./style.css"; +import { initializeTheme } from "./nightMode"; +const disposeTheme = initializeTheme(); +if (import.meta.hot) import.meta.hot.dispose(disposeTheme); createRoot(document.getElementById("root")!).render({window.location.hash.startsWith("#runtime-renew?") ? : }); diff --git a/web/src/nightMode.test.ts b/web/src/nightMode.test.ts index c1b6728..7896f1a 100644 --- a/web/src/nightMode.test.ts +++ b/web/src/nightMode.test.ts @@ -1,32 +1,91 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; -const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]})); -vi.mock('react', async importOriginal => ({ - ...await importOriginal(), - useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current:value}; }, - 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; }]; }, - useEffect(fn: () => (() => void) | undefined, deps: unknown[]) { - 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()}; }); +const hooks = vi.hoisted(() => ({cleanups: [] as (() => void)[], notify: vi.fn()})); +vi.mock('react', async original => ({ + ...await original(), + useSyncExternalStore(subscribe: (fn: () => void) => () => void, snapshot: () => unknown) { + hooks.cleanups.push(subscribe(hooks.notify)); + return snapshot(); }, })); -import { useNightMode } from './nightMode'; - -const media = {matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn()}; -const render = () => { hooks.cursor = 0; const result = useNightMode(); hooks.effects.splice(0).forEach(effect => effect()); return result; }; -beforeEach(() => { hooks.slots = []; hooks.cursor = 0; hooks.effects = []; media.matches = false; vi.clearAllMocks(); vi.stubGlobal('window', {matchMedia: vi.fn(() => media)}); }); -afterEach(() => { hooks.slots.forEach(s => s?.cleanup?.()); vi.unstubAllGlobals(); }); - -it('reflects the current system color scheme', () => { - media.matches = true; - expect(render()).toBe(true); - expect(media.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); +import { initializeTheme, setThemePreference, themeStorageKey, useNightMode, useThemePreference } from './nightMode'; +import { ThemeSettings } from './ThemeSettings'; +let cleanup: (() => void) | undefined; +let media: EventTarget & {matches: boolean}; +let win: EventTarget & {localStorage: {getItem: ReturnType; setItem: ReturnType}; matchMedia: ReturnType}; +let dataset: {theme?: string}; +let meta: {setAttribute: ReturnType}; +let saved: string | null; +beforeEach(() => { + saved = null; + 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(); }); - -it('updates when the system scheme changes and unsubscribes on cleanup', () => { - expect(render()).toBe(false); - const listener = media.addEventListener.mock.calls[0][1] as (event: MediaQueryListEvent) => void; - listener({matches: true} as MediaQueryListEvent); - expect(render()).toBe(true); - hooks.slots.forEach(s => s?.cleanup?.()); - expect(media.removeEventListener).toHaveBeenCalledWith('change', listener); +afterEach(() => {cleanup?.(); hooks.cleanups.splice(0).forEach(fn => fn()); vi.unstubAllGlobals();}); +function start() {cleanup = initializeTheme();} +function os(dark: boolean) {media.matches = dark; media.dispatchEvent(new Event('change'));} +function storage(value: string | null, key: string | null = themeStorageKey, area: unknown = win.localStorage) { + win.dispatchEvent(Object.assign(new Event('storage'), {key, newValue: value, storageArea: area})); +} +it('defaults to auto and applies OS changes to DOM, metadata and Monaco', () => { + 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 的主题独立管理'); }); diff --git a/web/src/nightMode.ts b/web/src/nightMode.ts index 476f884..feef0e3 100644 --- a/web/src/nightMode.ts +++ b/web/src/nightMode.ts @@ -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() { - const [dark, setDark] = useState(query().matches); - 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; + return useSyncExternalStore(subscribe, () => dark); } diff --git a/web/src/style.css b/web/src/style.css index 14c2c31..ffc986a 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -51,45 +51,40 @@ --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 { :root { color-scheme: light; } - @media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; - --color-background: #101c22; - --color-foreground: #d9e5e9; - --color-card: #17282f; - --color-card-foreground: #d9e5e9; - --color-muted: #1f333c; - --color-muted-foreground: #92acb6; - --color-border: #2c434d; - --color-input: #3a545f; - --color-ring: #4ec9ae; + :root[data-theme="dark"] { + color-scheme: dark; + --color-background: #101c22; + --color-foreground: #d9e5e9; + --color-card: #17282f; + --color-card-foreground: #d9e5e9; + --color-muted: #1f333c; + --color-muted-foreground: #92acb6; + --color-border: #2c434d; + --color-input: #3a545f; + --color-ring: #4ec9ae; - --color-primary-hover: #15a192; + --color-primary-hover: #15a192; - --color-secondary: #1e343c; - --color-secondary-foreground: #7fd8c5; + --color-secondary: #1e343c; + --color-secondary-foreground: #7fd8c5; - --color-destructive: #d45d53; - --color-destructive-muted: #3b2421; + --color-destructive: #d45d53; + --color-destructive-muted: #3b2421; - --color-success: #4cba8f; - --color-success-muted: #173a2e; - --color-warning: #d9a94f; - --color-warning-muted: #3b3018; - --color-danger-muted: #3b2421; + --color-success: #4cba8f; + --color-success-muted: #173a2e; + --color-warning: #d9a94f; + --color-warning-muted: #3b3018; + --color-danger-muted: #3b2421; - /* Navigation chrome (--color-nav-*) is already dark in both schemes. */ + /* Navigation chrome (--color-nav-*) is already dark in both schemes. */ - --shadow-card: 0 1px 2px rgb(0 0 0 / 0.35), 0 1px 6px rgb(0 0 0 / 0.3); - --shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55); - } + --shadow-card: 0 1px 2px rgb(0 0 0 / 0.35), 0 1px 6px rgb(0 0 0 / 0.3); + --shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55); } }