Compare commits
2
Commits
09e202796f
...
c6615faa49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6615faa49 | ||
|
|
602917698a |
@@ -8,8 +8,21 @@
|
|||||||
- 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);空表示不限制。大小写不敏感,非内容/恶意软件检测。
|
||||||
|
|
||||||
|
## 实例备份与快照管理
|
||||||
|
|
||||||
|
- `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 记录。不提供快照下载。
|
||||||
|
|
||||||
|
仅识别实例目录直属的 `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。
|
||||||
|
|||||||
@@ -118,6 +118,18 @@ 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(列表为空)。
|
||||||
|
|
||||||
## 会话有效期
|
## 会话有效期
|
||||||
|
|
||||||
在 `.env` 设置 `SESSION_TTL=24h`(默认),七天可设置为 `168h`。使用 Go duration 单位 `h`、`m`、`s`,不支持 `d`;必须至少一秒且为整秒,非法值会阻止启动。修改后运行 `docker compose up -d --force-recreate panel`。新登录使用新期限;已有会话保留原绝对到期时间,刷新不会延长。access token签发有效期为五分钟,但数据库会话过期或被撤销后,即使token尚未到期也不能认证。修改自己的密码或管理员重设密码会撤销该用户所有会话,需要重新登录。`.env` 由 Compose 读取,直接运行二进制时应在进程环境中导出 `SESSION_TTL`。
|
在 `.env` 设置 `SESSION_TTL=24h`(默认),七天可设置为 `168h`。使用 Go duration 单位 `h`、`m`、`s`,不支持 `d`;必须至少一秒且为整秒,非法值会阻止启动。修改后运行 `docker compose up -d --force-recreate panel`。新登录使用新期限;已有会话保留原绝对到期时间,刷新不会延长。access token签发有效期为五分钟,但数据库会话过期或被撤销后,即使token尚未到期也不能认证。修改自己的密码或管理员重设密码会撤销该用户所有会话,需要重新登录。`.env` 由 Compose 读取,直接运行二进制时应在进程环境中导出 `SESSION_TTL`。
|
||||||
|
|||||||
@@ -0,0 +1,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})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ 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" || (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()
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkspaceDownloadFilename(t *testing.T) {
|
||||||
|
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") })
|
||||||
|
if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home := filepath.Join(a.root, "workspaces/owner/instances/one/home")
|
||||||
|
checkFilename := func(t *testing.T, w *httptest.ResponseRecorder, want string) {
|
||||||
|
t.Helper()
|
||||||
|
header := w.Header().Get("Content-Disposition")
|
||||||
|
mediaType, params, err := mime.ParseMediaType(header)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse Content-Disposition %q: %v", header, err)
|
||||||
|
}
|
||||||
|
if mediaType != "attachment" || params["filename"] != want {
|
||||||
|
t.Fatalf("Content-Disposition = %q, want attachment with filename %q", header, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const content = "0123456789\nunchanged 下载 content\x00\xff"
|
||||||
|
for _, name := range []string{"report.txt", "中文文件.txt", "report with spaces.txt", `report";100%.txt`, "README"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
p := "parent/nested/" + name
|
||||||
|
file := filepath.Join(home, filepath.FromSlash(p))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(file), 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, partial := range []bool{false, true} {
|
||||||
|
r := runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid)
|
||||||
|
wantStatus, wantBody := http.StatusOK, content
|
||||||
|
if partial {
|
||||||
|
r.Header.Set("Range", "bytes=2-6")
|
||||||
|
wantStatus, wantBody = http.StatusPartialContent, content[2:7]
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.workspace(w, r)
|
||||||
|
if w.Code != wantStatus {
|
||||||
|
t.Fatalf("partial=%v: status = %d, want %d: %s", partial, w.Code, wantStatus, w.Body.String())
|
||||||
|
}
|
||||||
|
checkFilename(t, w, name)
|
||||||
|
if w.Body.String() != wantBody {
|
||||||
|
t.Errorf("partial=%v: body = %q, want %q", partial, w.Body.String(), wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("directory zip", func(t *testing.T) {
|
||||||
|
const name = `资料 ";100%`
|
||||||
|
p := "parent/" + name
|
||||||
|
dir := filepath.Join(home, filepath.FromSlash(p))
|
||||||
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "entry.txt"), []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.workspace(w, runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
checkFilename(t, w, name+".zip")
|
||||||
|
archive, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(archive.File) != 1 || archive.File[0].Name != "entry.txt" {
|
||||||
|
t.Fatalf("unexpected archive entries: %v", archive.File)
|
||||||
|
}
|
||||||
|
r, err := archive.File[0].Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
got, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != content {
|
||||||
|
t.Errorf("archive body = %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -781,7 +781,7 @@ func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root)
|
|||||||
fail(w, 400, "invalid file")
|
fail(w, 400, "invalid file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Disposition", "attachment")
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(p)}))
|
||||||
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
|
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -340,7 +340,7 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "profile-preview":
|
case "profile-preview":
|
||||||
a.profilePreview(w, r, i)
|
a.profilePreview(w, r, i)
|
||||||
return
|
return
|
||||||
case "profile", "backups", "restore":
|
case "profile", "backups", "restore", "snapshots":
|
||||||
a.config(w, r, i, action)
|
a.config(w, r, i, action)
|
||||||
return
|
return
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
@@ -535,6 +535,27 @@ func (a *API) runtimeOriginAllowed(r *http.Request) bool {
|
|||||||
|
|
||||||
var runtimeTransport = &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ResponseHeaderTimeout: 20 * time.Second, IdleConnTimeout: 90 * time.Second}
|
var runtimeTransport = &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ResponseHeaderTimeout: 20 * time.Second, IdleConnTimeout: 90 * time.Second}
|
||||||
|
|
||||||
|
// Office rendering can take longer than the normal API header deadline (the
|
||||||
|
// bundled converter defaults to 60 seconds). Reuse a separate connection pool
|
||||||
|
// without extending authentication or unrelated runtime requests.
|
||||||
|
var runtimeOfficeTransport = func() *http.Transport {
|
||||||
|
t := runtimeTransport.Clone()
|
||||||
|
t.ResponseHeaderTimeout = 120 * time.Second
|
||||||
|
return t
|
||||||
|
}()
|
||||||
|
|
||||||
|
type runtimeProxyTransport struct {
|
||||||
|
normal, office http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t runtimeProxyTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
// ReverseProxy has already removed the instance prefix here.
|
||||||
|
if r.URL.Path == "/api/officeToPdf/render" {
|
||||||
|
return t.office.RoundTrip(r)
|
||||||
|
}
|
||||||
|
return t.normal.RoundTrip(r)
|
||||||
|
}
|
||||||
|
|
||||||
type runtimeAuthKey struct {
|
type runtimeAuthKey struct {
|
||||||
api *API
|
api *API
|
||||||
id string
|
id string
|
||||||
@@ -1016,7 +1037,7 @@ func withoutRuntimeToken(rawQuery string) string {
|
|||||||
|
|
||||||
func runtimeReverseProxy(target *url.URL, prefix, host, cookies string) *httputil.ReverseProxy {
|
func runtimeReverseProxy(target *url.URL, prefix, host, cookies string) *httputil.ReverseProxy {
|
||||||
return &httputil.ReverseProxy{
|
return &httputil.ReverseProxy{
|
||||||
Transport: runtimeTransport, FlushInterval: -1, ErrorLog: log.New(io.Discard, "", 0),
|
Transport: runtimeProxyTransport{normal: runtimeTransport, office: runtimeOfficeTransport}, FlushInterval: -1, ErrorLog: log.New(io.Discard, "", 0),
|
||||||
Rewrite: func(p *httputil.ProxyRequest) {
|
Rewrite: func(p *httputil.ProxyRequest) {
|
||||||
p.SetURL(target)
|
p.SetURL(target)
|
||||||
p.Out.Host = host
|
p.Out.Host = host
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func officeTestProxy(t *testing.T, upstream string, officeTimeout time.Duration) *httputil.ReverseProxy {
|
||||||
|
t.Helper()
|
||||||
|
target, err := url.Parse(upstream)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
normal := runtimeTransport.Clone()
|
||||||
|
office := runtimeOfficeTransport.Clone()
|
||||||
|
normal.ResponseHeaderTimeout = 30 * time.Millisecond
|
||||||
|
office.ResponseHeaderTimeout = officeTimeout
|
||||||
|
t.Cleanup(normal.CloseIdleConnections)
|
||||||
|
t.Cleanup(office.CloseIdleConnections)
|
||||||
|
proxy := runtimeReverseProxy(target, "/dsh/office-test", "panel.test", "runtime=secret")
|
||||||
|
proxy.Transport = runtimeProxyTransport{normal: normal, office: office}
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeOfficeTransportDefaults(t *testing.T) {
|
||||||
|
if got := runtimeTransport.ResponseHeaderTimeout; got != 20*time.Second {
|
||||||
|
t.Errorf("normal timeout = %s, want 20s", got)
|
||||||
|
}
|
||||||
|
if got := runtimeOfficeTransport.ResponseHeaderTimeout; got != 120*time.Second {
|
||||||
|
t.Errorf("office timeout = %s, want 120s", got)
|
||||||
|
}
|
||||||
|
if runtimeTransport == runtimeOfficeTransport {
|
||||||
|
t.Fatal("office transport must be separate")
|
||||||
|
}
|
||||||
|
target, _ := url.Parse("http://panel.test")
|
||||||
|
proxy := runtimeReverseProxy(target, "/dsh/office-test", "panel.test", "")
|
||||||
|
transport, ok := proxy.Transport.(runtimeProxyTransport)
|
||||||
|
if !ok || transport.normal != runtimeTransport || transport.office != runtimeOfficeTransport {
|
||||||
|
t.Fatalf("unexpected default transport: %#v", proxy.Transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeOfficeProxyRouting(t *testing.T) {
|
||||||
|
for _, path := range []string{
|
||||||
|
"/api/officeToPdf/render",
|
||||||
|
"/api/status",
|
||||||
|
"/api/officeToPdf/render/",
|
||||||
|
"/api/officeToPdf/render/child",
|
||||||
|
"/api/officeToPdf/renderOther",
|
||||||
|
"/api/officetopdf/render",
|
||||||
|
"/api/officeToPdf/status",
|
||||||
|
} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
const body = "document payload"
|
||||||
|
const query = "file=report%20one.docx&mode=pdf&mode=preview"
|
||||||
|
var calls atomic.Int32
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
data, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil || string(data) != body || r.Method != http.MethodPost {
|
||||||
|
t.Errorf("upstream method/body = %s %q, error %v", r.Method, data, err)
|
||||||
|
}
|
||||||
|
if r.URL.Path != path || r.URL.RawQuery != query {
|
||||||
|
t.Errorf("upstream URL = %s", r.URL)
|
||||||
|
}
|
||||||
|
if r.Host != "panel.test" || r.Header.Get("X-Forwarded-Prefix") != "/dsh/office-test" || r.Header.Get("Cookie") != "runtime=secret" {
|
||||||
|
t.Errorf("upstream host/headers = %s %v", r.Host, r.Header)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
w.Header().Set("Content-Type", "application/pdf")
|
||||||
|
_, _ = io.WriteString(w, "rendered PDF")
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
proxy := officeTestProxy(t, upstream.URL, 500*time.Millisecond)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/dsh/office-test"+path+"?"+query+"&token=removed", strings.NewReader(body))
|
||||||
|
req.Header.Set("Cookie", "panel=private")
|
||||||
|
originalURL := req.URL.String()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
proxy.ServeHTTP(w, req)
|
||||||
|
want := http.StatusBadGateway
|
||||||
|
if path == "/api/officeToPdf/render" {
|
||||||
|
want = http.StatusOK
|
||||||
|
if w.Body.String() != "rendered PDF" {
|
||||||
|
t.Errorf("response body = %q", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.Code != want {
|
||||||
|
t.Errorf("status = %d, want %d: %s", w.Code, want, w.Body.String())
|
||||||
|
}
|
||||||
|
if req.URL.String() != originalURL || req.Header.Get("Cookie") != "panel=private" {
|
||||||
|
t.Error("proxy mutated incoming URL or cookies")
|
||||||
|
}
|
||||||
|
upstream.Close()
|
||||||
|
if got := calls.Load(); got != 1 {
|
||||||
|
t.Errorf("mutating request sent %d times, want exactly once", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeOfficeProxyFiniteTimeoutAndCancellation(t *testing.T) {
|
||||||
|
for _, name := range []string{"header timeout", "caller cancellation"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
started := make(chan struct{}, 2)
|
||||||
|
canceled := make(chan struct{}, 2)
|
||||||
|
var calls atomic.Int32
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
started <- struct{}{}
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
canceled <- struct{}{}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
officeTimeout := 100 * time.Millisecond
|
||||||
|
if name == "caller cancellation" {
|
||||||
|
officeTimeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
proxy := officeTestProxy(t, upstream.URL, officeTimeout)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/dsh/office-test/api/officeToPdf/render?mode=pdf", strings.NewReader("document")).WithContext(ctx)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
proxy.ServeHTTP(w, req)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-started:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("request never reached upstream")
|
||||||
|
}
|
||||||
|
if name == "caller cancellation" {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("proxy did not promptly finish")
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want 502", w.Code)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-canceled:
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
t.Fatal("upstream context was not canceled")
|
||||||
|
}
|
||||||
|
upstream.Close()
|
||||||
|
if got := calls.Load(); got != 1 {
|
||||||
|
t.Errorf("mutating request sent %d times, want exactly once", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" media="(prefers-color-scheme: light)" content="#152b36"/><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#101c22"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" content="#152b36"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||||
|
|||||||
@@ -12,6 +12,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 工作台'},
|
||||||
@@ -121,6 +122,7 @@ function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: ()
|
|||||||
{page === 'workspace' && <Workspace runtime={runtime} />}
|
{page === 'workspace' && <Workspace runtime={runtime} />}
|
||||||
{page === 'admin' && user.admin && <Admin />}
|
{page === 'admin' && user.admin && <Admin />}
|
||||||
{page === 'settings' && <>
|
{page === 'settings' && <>
|
||||||
|
<ThemeSettings />
|
||||||
<Panel title="DSH 实例" action={<Button variant="ghost" size="sm" disabled={runtime.loading} onClick={() => void runtime.refresh()}>刷新</Button>}>
|
<Panel title="DSH 实例" action={<Button variant="ghost" size="sm" disabled={runtime.loading} onClick={() => void runtime.refresh()}>刷新</Button>}>
|
||||||
{runtime.error && <Alert>{runtime.error}<Button variant="outline" size="sm" className="ml-2" disabled={runtime.loading} onClick={() => void runtime.refresh()}>重试</Button></Alert>}
|
{runtime.error && <Alert>{runtime.error}<Button variant="outline" size="sm" className="ml-2" disabled={runtime.loading} onClick={() => void runtime.refresh()}>重试</Button></Alert>}
|
||||||
{runtime.loading && <p role="status" className="m-0 mb-3 text-[13px] text-muted-foreground">正在刷新实例…</p>}
|
{runtime.loading && <p role="status" className="m-0 mb-3 text-[13px] text-muted-foreground">正在刷新实例…</p>}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, expect, it, vi } from 'vitest';
|
import { beforeEach, expect, it, vi } from 'vitest';
|
||||||
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
|
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0, resourceCursor: 0, snapshots: [] as any[], refreshStorage: vi.fn(async () => {})}));
|
||||||
vi.mock('react', async original => ({
|
vi.mock('react', async original => ({
|
||||||
...await original<typeof import('react')>(),
|
...await original<typeof import('react')>(),
|
||||||
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
|
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
|
||||||
@@ -8,7 +8,7 @@ vi.mock('react', async original => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('./ui', async original => ({
|
vi.mock('./ui', async original => ({
|
||||||
...await original<typeof import('./ui')>(),
|
...await original<typeof import('./ui')>(),
|
||||||
useResource: () => ({data: [{id: 'backup', created_at: '', size: 1}], loading: false, refresh: vi.fn(async () => {})}),
|
useResource: () => ({data: state.resourceCursor++ % 2 === 0 ? [{id: 'backup', created_at: '', size: 1}] : state.snapshots, loading: false, refresh: state.refreshStorage}),
|
||||||
}));
|
}));
|
||||||
import { BackupPanel } from './BackupPanel';
|
import { BackupPanel } from './BackupPanel';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
@@ -20,12 +20,13 @@ function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
|||||||
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||||
}
|
}
|
||||||
const refresh = vi.fn(async () => {});
|
const refresh = vi.fn(async () => {});
|
||||||
function render() {state.cursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh});}
|
const onRestored = vi.fn(async () => {});
|
||||||
|
function render() {state.cursor = 0; state.resourceCursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh, onRestored});}
|
||||||
function editor() {
|
function editor() {
|
||||||
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
|
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
|
||||||
return child.type(child.props);
|
return child.type(child.props);
|
||||||
}
|
}
|
||||||
beforeEach(() => {state.slots = []; state.cursor = 0; vi.restoreAllMocks(); refresh.mockClear();});
|
beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); 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,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(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();
|
||||||
});
|
});
|
||||||
|
|||||||
+46
-6
@@ -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,29 @@ 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>();
|
||||||
|
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) {
|
function createBackup(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -44,7 +59,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">
|
||||||
@@ -71,6 +89,8 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!restoreTarget && action.feedback}
|
{!restoreTarget && 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,13 +99,33 @@ 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 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。快照暂不支持网页下载,需要导出时请联系管理员。删除不可撤销,待恢复实例的快照受保护。</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>}
|
||||||
|
</div>
|
||||||
|
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy || s.protected} onClick={() => void remove(s, true)}><Trash2 aria-hidden />删除快照</button>
|
||||||
|
</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);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
slots: [] as any[], cursor: 0, resourceCursor: 0, resources: [] as any[],
|
||||||
|
}));
|
||||||
|
vi.mock('react', async original => ({
|
||||||
|
...await original<typeof import('react')>(),
|
||||||
|
useState(value: unknown) {
|
||||||
|
const slots = state.slots, i = state.cursor++;
|
||||||
|
if (!(i in slots)) slots[i] = value;
|
||||||
|
return [slots[i], (next: unknown) => {slots[i] = typeof next === 'function' ? next(slots[i]) : next;}];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./ui', async original => ({
|
||||||
|
...await original<typeof import('./ui')>(),
|
||||||
|
useResource: () => state.resources[state.resourceCursor++],
|
||||||
|
useAction: () => ({busy: false, feedback: null}),
|
||||||
|
}));
|
||||||
|
vi.mock('./components/ConfirmDialog', () => ({useConfirm: () => ({element: null})}));
|
||||||
|
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
|
||||||
|
|
||||||
|
import { Instances } from './Instances';
|
||||||
|
import { ProfileEditor } from './ProfileEditor';
|
||||||
|
import { BackupPanel } from './BackupPanel';
|
||||||
|
|
||||||
|
function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
||||||
|
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
|
||||||
|
if (!root || typeof root !== 'object') return [];
|
||||||
|
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preserves the unsaved Profile draft on backup refresh and reloads it only after restore', async () => {
|
||||||
|
const instance = {id: 'one', name: 'Test instance', status: 'stopped', created_at: '', version: 'test'};
|
||||||
|
const instances = {data: [instance], loading: false, refresh: vi.fn(async () => {})};
|
||||||
|
const usage = {data: {used_bytes: 1, quota_bytes: 100}, loading: false, refresh: vi.fn(async () => {})};
|
||||||
|
const profile = {
|
||||||
|
data: {content: 'name: original'}, loading: false,
|
||||||
|
refresh: vi.fn(async () => {profile.data = {content: 'name: restored'};}),
|
||||||
|
};
|
||||||
|
const parentSlots: any[] = [], detailSlots: any[] = [];
|
||||||
|
function renderParent() {
|
||||||
|
state.slots = parentSlots; state.cursor = 0; state.resourceCursor = 0;
|
||||||
|
state.resources = [instances, usage];
|
||||||
|
return Instances();
|
||||||
|
}
|
||||||
|
function renderDetail() {
|
||||||
|
const detail = nodes(renderParent(), n => typeof n.type === 'function' && n.props.instance?.id === instance.id)[0];
|
||||||
|
expect(detail).toBeDefined();
|
||||||
|
state.slots = detailSlots; state.cursor = 0; state.resourceCursor = 0;
|
||||||
|
state.resources = [{data: []}, profile, {data: undefined}, {data: undefined}];
|
||||||
|
return detail.type(detail.props);
|
||||||
|
}
|
||||||
|
const editor = (tree: any) => nodes(tree, n => n.type === ProfileEditor && n.props.onChange)[0];
|
||||||
|
const backup = (tree: any) => nodes(tree, n => n.type === BackupPanel)[0];
|
||||||
|
const save = (tree: any) => nodes(tree, n => n.type === 'button' && n.props.children === '保存并启动')[0];
|
||||||
|
|
||||||
|
nodes(renderParent(), n => n.type === 'button' && n.props.children === '管理')[0].props.onClick();
|
||||||
|
let tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: original');
|
||||||
|
expect(save(tree).props.disabled).toBe(true);
|
||||||
|
editor(tree).props.onChange('name: unsaved draft');
|
||||||
|
tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: unsaved draft');
|
||||||
|
expect(save(tree).props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await backup(tree).props.refresh();
|
||||||
|
expect(instances.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(usage.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(profile.refresh).not.toHaveBeenCalled();
|
||||||
|
tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: unsaved draft');
|
||||||
|
expect(save(tree).props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await backup(tree).props.onRestored();
|
||||||
|
expect(profile.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(instances.refresh).toHaveBeenCalledTimes(2);
|
||||||
|
expect(usage.refresh).toHaveBeenCalledTimes(2);
|
||||||
|
tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: restored');
|
||||||
|
expect(save(tree).props.disabled).toBe(true);
|
||||||
|
});
|
||||||
@@ -100,7 +100,7 @@ export function Instances() {
|
|||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
</Panel>
|
</Panel>
|
||||||
{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={instances.refresh} />}
|
{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={async () => { await Promise.all([instances.refresh(), usage.refresh()]); }} />}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ function InstanceDetail({instance, close, refresh}: {instance: Instance; close:
|
|||||||
</>}
|
</>}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<BackupPanel instance={instance} refresh={async () => { await profile.refresh(); setContent(undefined); await refresh(); }} />
|
<BackupPanel instance={instance} refresh={refresh} onRestored={async () => { await profile.refresh(); setContent(undefined); await refresh(); }} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="upgrade">
|
<TabsContent value="upgrade">
|
||||||
<form className="grid max-w-xl gap-4" onSubmit={async e => {
|
<form className="grid max-w-xl gap-4" onSubmit={async e => {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Panel } from './ui';
|
||||||
|
import { Button } from './components/ui/button';
|
||||||
|
import { setThemePreference, useThemePreference, type ThemePreference } from './nightMode';
|
||||||
|
|
||||||
|
const options: {value: ThemePreference; label: string}[] = [
|
||||||
|
{value: 'light', label: '日间'},
|
||||||
|
{value: 'dark', label: '夜间'},
|
||||||
|
{value: 'auto', label: '自动(跟随系统)'},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ThemeSettings() {
|
||||||
|
const preference = useThemePreference();
|
||||||
|
return <Panel title="外观">
|
||||||
|
<div role="group" aria-label="工作台主题" className="flex flex-wrap gap-2">
|
||||||
|
{options.map(option => <Button key={option.value} type="button"
|
||||||
|
variant={preference === option.value ? 'default' : 'outline'}
|
||||||
|
aria-pressed={preference === option.value} onClick={() => setThemePreference(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</Button>)}
|
||||||
|
</div>
|
||||||
|
<p className="mb-0 mt-3 text-[13px] text-muted-foreground">仅在此浏览器保存,适用于工作台、登录页和文件编辑器。自动模式随系统实时切换;内嵌 DSH 的主题独立管理,不受此设置影响。</p>
|
||||||
|
</Panel>;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface Entry { name: string; path?: string; is_dir: boolean; size: num
|
|||||||
export interface Usage { used_bytes: number; quota_bytes: number }
|
export interface Usage { used_bytes: number; quota_bytes: number }
|
||||||
export interface TextFile { content: string; version: string }
|
export interface TextFile { content: string; version: string }
|
||||||
export interface Backup { id: string; created_at: string; size: number }
|
export interface Backup { id: string; created_at: string; size: number }
|
||||||
|
export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string }
|
||||||
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
|
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
|
||||||
export function createApi(fetcher: typeof fetch = fetch) {
|
export function createApi(fetcher: typeof fetch = fetch) {
|
||||||
let csrf = '';
|
let csrf = '';
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { RuntimeRenew } from "./RuntimeRenew";
|
import { RuntimeRenew } from "./RuntimeRenew";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
import { initializeTheme } from "./nightMode";
|
||||||
|
const disposeTheme = initializeTheme();
|
||||||
|
if (import.meta.hot) import.meta.hot.dispose(disposeTheme);
|
||||||
createRoot(document.getElementById("root")!).render(<React.StrictMode>{window.location.hash.startsWith("#runtime-renew?") ? <RuntimeRenew /> : <App />}</React.StrictMode>);
|
createRoot(document.getElementById("root")!).render(<React.StrictMode>{window.location.hash.startsWith("#runtime-renew?") ? <RuntimeRenew /> : <App />}</React.StrictMode>);
|
||||||
|
|||||||
+86
-27
@@ -1,32 +1,91 @@
|
|||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
|
const hooks = vi.hoisted(() => ({cleanups: [] as (() => void)[], notify: vi.fn()}));
|
||||||
vi.mock('react', async importOriginal => ({
|
vi.mock('react', async original => ({
|
||||||
...await importOriginal<typeof import('react')>(),
|
...await original<typeof import('react')>(),
|
||||||
useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current:value}; },
|
useSyncExternalStore(subscribe: (fn: () => void) => () => void, snapshot: () => unknown) {
|
||||||
useState(value: unknown) { const i = hooks.cursor++; if (!(i in hooks.slots)) hooks.slots[i] = value; return [hooks.slots[i], (v: unknown) => { hooks.slots[i] = v; }]; },
|
hooks.cleanups.push(subscribe(hooks.notify));
|
||||||
useEffect(fn: () => (() => void) | undefined, deps: unknown[]) {
|
return snapshot();
|
||||||
const i = hooks.cursor++, previous = hooks.slots[i];
|
|
||||||
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.effects.push(() => { previous?.cleanup?.(); hooks.slots[i] = {deps, cleanup:fn()}; });
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
import { useNightMode } from './nightMode';
|
import { initializeTheme, setThemePreference, themeStorageKey, useNightMode, useThemePreference } from './nightMode';
|
||||||
|
import { ThemeSettings } from './ThemeSettings';
|
||||||
const media = {matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn()};
|
let cleanup: (() => void) | undefined;
|
||||||
const render = () => { hooks.cursor = 0; const result = useNightMode(); hooks.effects.splice(0).forEach(effect => effect()); return result; };
|
let media: EventTarget & {matches: boolean};
|
||||||
beforeEach(() => { hooks.slots = []; hooks.cursor = 0; hooks.effects = []; media.matches = false; vi.clearAllMocks(); vi.stubGlobal('window', {matchMedia: vi.fn(() => media)}); });
|
let win: EventTarget & {localStorage: {getItem: ReturnType<typeof vi.fn>; setItem: ReturnType<typeof vi.fn>}; matchMedia: ReturnType<typeof vi.fn>};
|
||||||
afterEach(() => { hooks.slots.forEach(s => s?.cleanup?.()); vi.unstubAllGlobals(); });
|
let dataset: {theme?: string};
|
||||||
|
let meta: {setAttribute: ReturnType<typeof vi.fn>};
|
||||||
it('reflects the current system color scheme', () => {
|
let saved: string | null;
|
||||||
media.matches = true;
|
beforeEach(() => {
|
||||||
expect(render()).toBe(true);
|
saved = null;
|
||||||
expect(media.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
media = Object.assign(new EventTarget(), {matches: false});
|
||||||
|
win = Object.assign(new EventTarget(), {localStorage: {getItem: vi.fn(() => saved), setItem: vi.fn((_key, value) => {saved = value;})}, matchMedia: vi.fn(() => media)});
|
||||||
|
dataset = {}; meta = {setAttribute: vi.fn()};
|
||||||
|
vi.stubGlobal('window', win);
|
||||||
|
vi.stubGlobal('document', {documentElement: {dataset}, querySelector: vi.fn(() => meta)});
|
||||||
|
hooks.notify.mockClear();
|
||||||
});
|
});
|
||||||
|
afterEach(() => {cleanup?.(); hooks.cleanups.splice(0).forEach(fn => fn()); vi.unstubAllGlobals();});
|
||||||
it('updates when the system scheme changes and unsubscribes on cleanup', () => {
|
function start() {cleanup = initializeTheme();}
|
||||||
expect(render()).toBe(false);
|
function os(dark: boolean) {media.matches = dark; media.dispatchEvent(new Event('change'));}
|
||||||
const listener = media.addEventListener.mock.calls[0][1] as (event: MediaQueryListEvent) => void;
|
function storage(value: string | null, key: string | null = themeStorageKey, area: unknown = win.localStorage) {
|
||||||
listener({matches: true} as MediaQueryListEvent);
|
win.dispatchEvent(Object.assign(new Event('storage'), {key, newValue: value, storageArea: area}));
|
||||||
expect(render()).toBe(true);
|
}
|
||||||
hooks.slots.forEach(s => s?.cleanup?.());
|
it('defaults to auto and applies OS changes to DOM, metadata and Monaco', () => {
|
||||||
expect(media.removeEventListener).toHaveBeenCalledWith('change', listener);
|
start(); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(false);
|
||||||
|
os(true); expect(useNightMode()).toBe(true); expect(hooks.notify).toHaveBeenCalled();
|
||||||
|
expect(dataset.theme).toBe('dark');
|
||||||
|
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#101c22');
|
||||||
|
os(false); expect(dataset.theme).toBe('light');
|
||||||
|
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#152b36');
|
||||||
|
});
|
||||||
|
it.each(['light', 'dark'] as const)('persists %s and overrides either OS scheme across initialization', value => {
|
||||||
|
start(); setThemePreference(value);
|
||||||
|
for (const system of [true, false]) {os(system); expect(useNightMode()).toBe(value === 'dark');}
|
||||||
|
expect(win.localStorage.setItem).toHaveBeenCalledWith(themeStorageKey, value);
|
||||||
|
cleanup!(); start(); expect(useThemePreference()).toBe(value); expect(dataset.theme).toBe(value);
|
||||||
|
setThemePreference('auto'); os(true); expect(useNightMode()).toBe(true); expect(saved).toBe('auto');
|
||||||
|
});
|
||||||
|
it.each([null, '', 'invalid', 'DARK', 'auto'])('treats stored %s as auto', value => {
|
||||||
|
saved = value; media.matches = true; start();
|
||||||
|
expect(useThemePreference()).toBe('auto'); expect(dataset.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
it('synchronizes tabs including removal, clear and invalid values without writing back', () => {
|
||||||
|
start(); storage('dark'); expect(useNightMode()).toBe(true);
|
||||||
|
storage('light'); expect(useThemePreference()).toBe('light'); os(true); expect(useNightMode()).toBe(false);
|
||||||
|
for (const value of [null, 'bad', 'auto']) {storage(value); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(true);}
|
||||||
|
storage('light'); storage(null, null); expect(useThemePreference()).toBe('auto');
|
||||||
|
storage('light', 'unrelated'); storage('light', themeStorageKey, {});
|
||||||
|
expect(useThemePreference()).toBe('auto'); expect(win.localStorage.setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('keeps manual choices usable with blocked storage', () => {
|
||||||
|
Object.defineProperty(win, 'localStorage', {get() {throw new Error('blocked');}});
|
||||||
|
start(); expect(useThemePreference()).toBe('auto');
|
||||||
|
setThemePreference('dark'); os(false); expect(useNightMode()).toBe(true);
|
||||||
|
setThemePreference('light'); os(true); expect(useNightMode()).toBe(false);
|
||||||
|
setThemePreference('auto'); expect(useNightMode()).toBe(true);
|
||||||
|
});
|
||||||
|
it('keeps a selection when storage writes fail', () => {
|
||||||
|
start(); win.localStorage.setItem.mockImplementation(() => {throw new Error('quota');});
|
||||||
|
setThemePreference('dark'); expect(dataset.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
it('initializes once and cleans up global listeners and hook subscriptions', () => {
|
||||||
|
const removeMedia = vi.spyOn(media, 'removeEventListener');
|
||||||
|
const removeWindow = vi.spyOn(win, 'removeEventListener');
|
||||||
|
start(); expect(initializeTheme()).toBe(cleanup); expect(win.matchMedia).toHaveBeenCalledOnce();
|
||||||
|
useNightMode(); hooks.cleanups.splice(0).forEach(fn => fn());
|
||||||
|
hooks.notify.mockClear(); setThemePreference('light'); expect(hooks.notify).not.toHaveBeenCalled();
|
||||||
|
cleanup!(); cleanup = undefined;
|
||||||
|
expect(removeMedia).toHaveBeenCalledWith('change', expect.any(Function));
|
||||||
|
expect(removeWindow).toHaveBeenCalledWith('storage', expect.any(Function));
|
||||||
|
os(true); storage('dark'); expect(dataset.theme).toBe('light');
|
||||||
|
});
|
||||||
|
it('settings buttons expose one pressed choice and explain DSH independence', () => {
|
||||||
|
start(); const buttons = () => ThemeSettings().props.children[0].props.children;
|
||||||
|
expect(buttons().map((b: any) => b.props['aria-pressed'])).toEqual([false, false, true]);
|
||||||
|
for (const [index, value] of ['light', 'dark', 'auto'].entries()) {
|
||||||
|
buttons()[index].props.onClick(); expect(useThemePreference()).toBe(value);
|
||||||
|
expect(buttons().filter((b: any) => b.props['aria-pressed'])).toHaveLength(1);
|
||||||
|
expect(buttons()[index].props['aria-pressed']).toBe(true);
|
||||||
|
}
|
||||||
|
expect(JSON.stringify(ThemeSettings())).toContain('内嵌 DSH 的主题独立管理');
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-12
@@ -1,16 +1,58 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
const query = () => window.matchMedia('(prefers-color-scheme: dark)');
|
export type ThemePreference = 'light' | 'dark' | 'auto';
|
||||||
|
export const themeStorageKey = 'dsh.console.theme';
|
||||||
|
let preference: ThemePreference = 'auto';
|
||||||
|
let dark = false;
|
||||||
|
let media: MediaQueryList | undefined;
|
||||||
|
let dispose: (() => void) | undefined;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const normalize = (value: string | null): ThemePreference => value === 'light' || value === 'dark' ? value : 'auto';
|
||||||
|
|
||||||
/** Tracks the OS dark-scheme preference, matching the automatic night mode of embedded DSH. */
|
function update() {
|
||||||
|
dark = preference === 'dark' || (preference === 'auto' && !!media?.matches);
|
||||||
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||||
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#101c22' : '#152b36');
|
||||||
|
listeners.forEach(listener => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeTheme() {
|
||||||
|
if (dispose) return dispose;
|
||||||
|
try { preference = normalize(window.localStorage.getItem(themeStorageKey)); } catch { preference = 'auto'; }
|
||||||
|
media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const storage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== themeStorageKey && event.key !== null) return;
|
||||||
|
try { if (event.storageArea !== window.localStorage) return; } catch { return; }
|
||||||
|
preference = normalize(event.key === null ? null : event.newValue);
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
media.addEventListener('change', update);
|
||||||
|
window.addEventListener('storage', storage);
|
||||||
|
update();
|
||||||
|
const currentMedia = media;
|
||||||
|
dispose = () => {
|
||||||
|
currentMedia.removeEventListener('change', update);
|
||||||
|
window.removeEventListener('storage', storage);
|
||||||
|
dispose = undefined;
|
||||||
|
};
|
||||||
|
return dispose;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThemePreference(value: ThemePreference) {
|
||||||
|
preference = value;
|
||||||
|
try { window.localStorage.setItem(themeStorageKey, value); } catch { /* Keep the selection for this page when storage is blocked. */ }
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener: () => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => { listeners.delete(listener); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemePreference() {
|
||||||
|
return useSyncExternalStore(subscribe, () => preference);
|
||||||
|
}
|
||||||
|
|
||||||
export function useNightMode() {
|
export function useNightMode() {
|
||||||
const [dark, setDark] = useState(query().matches);
|
return useSyncExternalStore(subscribe, () => dark);
|
||||||
useEffect(() => {
|
|
||||||
const media = query();
|
|
||||||
const update = (event: MediaQueryListEvent) => setDark(event.matches);
|
|
||||||
setDark(media.matches);
|
|
||||||
media.addEventListener('change', update);
|
|
||||||
return () => media.removeEventListener('change', update);
|
|
||||||
}, []);
|
|
||||||
return dark;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-29
@@ -51,45 +51,40 @@
|
|||||||
--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;
|
--color-card: #17282f;
|
||||||
--color-card: #17282f;
|
--color-card-foreground: #d9e5e9;
|
||||||
--color-card-foreground: #d9e5e9;
|
--color-muted: #1f333c;
|
||||||
--color-muted: #1f333c;
|
--color-muted-foreground: #92acb6;
|
||||||
--color-muted-foreground: #92acb6;
|
--color-border: #2c434d;
|
||||||
--color-border: #2c434d;
|
--color-input: #3a545f;
|
||||||
--color-input: #3a545f;
|
--color-ring: #4ec9ae;
|
||||||
--color-ring: #4ec9ae;
|
|
||||||
|
|
||||||
--color-primary-hover: #15a192;
|
--color-primary-hover: #15a192;
|
||||||
|
|
||||||
--color-secondary: #1e343c;
|
--color-secondary: #1e343c;
|
||||||
--color-secondary-foreground: #7fd8c5;
|
--color-secondary-foreground: #7fd8c5;
|
||||||
|
|
||||||
--color-destructive: #d45d53;
|
--color-destructive: #d45d53;
|
||||||
--color-destructive-muted: #3b2421;
|
--color-destructive-muted: #3b2421;
|
||||||
|
|
||||||
--color-success: #4cba8f;
|
--color-success: #4cba8f;
|
||||||
--color-success-muted: #173a2e;
|
--color-success-muted: #173a2e;
|
||||||
--color-warning: #d9a94f;
|
--color-warning: #d9a94f;
|
||||||
--color-warning-muted: #3b3018;
|
--color-warning-muted: #3b3018;
|
||||||
--color-danger-muted: #3b2421;
|
--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-card: 0 1px 2px rgb(0 0 0 / 0.35), 0 1px 6px rgb(0 0 0 / 0.3);
|
||||||
--shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55);
|
--shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user