package api import ( "context" "database/sql" "errors" "io/fs" "net/http" "os" "regexp" "strings" "time" "git.misaka.ren/m1saka/dsh-web/internal/store" ) var snapshotIDPattern = regexp.MustCompile(`^(pre-restore-[a-f0-9]{32}|failed-restore-[a-f0-9]{32}|upgrade-[a-f0-9]{32}-snapshot|failed-home-[a-f0-9]{32})$`) var errUnsafeManagementPath = errors.New("unsafe storage path") func managementPathError(w http.ResponseWriter, err error) { if errors.Is(err, errUnsafeManagementPath) { fail(w, 400, "unsafe storage path") return } fail(w, 500, "storage unavailable") } type snapshotInfo struct { ID string `json:"id"` Size int64 `json:"size"` CreatedAt time.Time `json:"created_at"` Protected bool `json:"protected"` ProtectionReason string `json:"protection_reason"` SizeError string `json:"size_error,omitempty"` Restorable bool `json:"restorable"` RestoreReason string `json:"restore_reason"` SourceVersion string `json:"source_version,omitempty"` SourceImage string `json:"source_image,omitempty"` } func managementRoot(base string, parts ...string) (*os.Root, error) { root, err := os.OpenRoot(base) if err != nil { return nil, err } for _, part := range parts { if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "/\\") { root.Close() return nil, errUnsafeManagementPath } st, err := root.Lstat(part) if err != nil { root.Close() return nil, err } if !st.IsDir() { root.Close() return nil, errUnsafeManagementPath } next, err := root.OpenRoot(part) root.Close() if err != nil { return nil, err } actual, err := next.Stat(".") if err != nil { next.Close() return nil, err } if !os.SameFile(st, actual) { next.Close() return nil, errUnsafeManagementPath } root = next } return root, nil } func (a *API) backupProtection(i store.Instance) string { if i.Status == "error" { return "instance recovery required" } var exists, pending int if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&exists); err != nil { return "recovery journal unavailable" } if exists != 0 { if err := a.s.DB.QueryRow("SELECT count(*) FROM upgrade_journal WHERE instance_id=? AND (phase IS NULL OR phase NOT IN ('complete','rolled_back'))", i.ID).Scan(&pending); err != nil { return "recovery journal unavailable" } if pending != 0 { return "instance recovery required" } } return "" } func snapshotBytes(ctx context.Context, root *os.Root, name string) (int64, error) { var total int64 count := 0 err := fs.WalkDir(root.FS(), name, func(p string, d fs.DirEntry, err error) error { if ctx.Err() != nil { return ctx.Err() } if err != nil { return err } count++ if count > 100000 || strings.Count(p, "/") > 128 { return errors.New("tree limit") } st, err := d.Info() if err != nil { return err } if st.Mode().IsRegular() { total += st.Size() } return nil }) return total, err } func (a *API) backupManagement(w http.ResponseWriter, r *http.Request, i store.Instance, action string) { w, flush := deferJSONResponse(w) defer flush() if r.Method != http.MethodDelete && !(action == "snapshots" && r.Method == http.MethodGet) { fail(w, 405, "method not allowed") return } id := r.URL.Query().Get("id") if r.Method == http.MethodDelete && ((action == "snapshots" && !snapshotIDPattern.MatchString(id)) || (action == "backups" && !backupIDPattern.MatchString(id))) { fail(w, 400, "invalid ID") return } lock := a.instanceLocks.Acquire(i.ID) defer lock.Release() user := a.userLocks.Acquire(i.UID) defer user.Release() current, err := a.s.GetInstance(i.ID) if err != nil || current.UID != i.UID { fail(w, 404, "not found") return } i = current reason := a.backupProtection(i) if r.Method == http.MethodDelete && reason != "" { fail(w, 409, reason) return } parts := []string{"workspaces", i.UID, "instances", i.ID} if action == "backups" { parts = []string{"backups", i.UID, i.ID} } root, err := managementRoot(a.root, parts...) if errors.Is(err, os.ErrNotExist) { if action == "backups" { a.deleteBackup(w, i, nil, id) return } if r.Method == http.MethodGet { writeJSON(w, []snapshotInfo{}) return } fail(w, 404, "not found") return } if err != nil { managementPathError(w, err) return } defer root.Close() if r.Method == http.MethodGet { f, err := root.Open(".") if err != nil { fail(w, 500, "snapshot listing failed") return } entries, err := f.ReadDir(-1) f.Close() if err != nil { fail(w, 500, "snapshot listing failed") return } out := []snapshotInfo{} for _, entry := range entries { if !snapshotIDPattern.MatchString(entry.Name()) || !entry.IsDir() { continue } st, err := entry.Info() if err != nil { fail(w, 500, "snapshot metadata unavailable") return } s := snapshotInfo{ID: entry.Name(), CreatedAt: st.ModTime(), Protected: reason != "", ProtectionReason: reason} s.SourceVersion, s.SourceImage, s.RestoreReason = a.snapshotCompatibility(i, s.ID) s.Restorable = s.RestoreReason == "" || s.RestoreReason == snapshotUnknownReason if reason != "" { s.Restorable = false s.RestoreReason = reason } s.Size, err = snapshotBytes(r.Context(), root, entry.Name()) if err != nil { s.Size = -1 s.SizeError = "snapshot size unavailable: " + err.Error() s.Restorable = false s.RestoreReason = "snapshot size unavailable" } out = append(out, s) } writeJSON(w, out) return } if action == "backups" { a.deleteBackup(w, i, root, id) return } st, err := root.Lstat(id) if errors.Is(err, os.ErrNotExist) { fail(w, 404, "snapshot not found") return } if err != nil { managementPathError(w, err) return } if !st.IsDir() { fail(w, 400, "unsafe snapshot path") return } a.deleteSnapshot(w, i, id, func() error { return root.RemoveAll(id) }) } func (a *API) deleteSnapshot(w http.ResponseWriter, i store.Instance, id string, remove func() error) { audit := func(state string) error { _, err := a.s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "snapshot.delete."+state, i.ID, id, time.Now().Unix()) return err } if err := audit("started"); err != nil { fail(w, 500, "audit unavailable") return } if err := remove(); err != nil { if err := audit("failed"); err != nil { fail(w, 500, "snapshot deletion failed; audit unavailable") return } fail(w, 500, "snapshot deletion failed") return } if err := audit("completed"); err != nil { fail(w, 500, "snapshot removed; completion audit unavailable") return } writeJSON(w, map[string]bool{"ok": true}) } func (a *API) deleteBackup(w http.ResponseWriter, i store.Instance, root *os.Root, id string) { name := id + ".dshbak" var st os.FileInfo statErr := error(os.ErrNotExist) if root != nil { st, statErr = root.Lstat(name) } if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { fail(w, 500, "backup unavailable") return } if statErr == nil && !st.Mode().IsRegular() { fail(w, 400, "unsafe backup path") return } tx, err := a.s.DB.Begin() if err != nil { fail(w, 500, "backup index unavailable") return } defer tx.Rollback() var exists, indexed int err = tx.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='backups'").Scan(&exists) if err == nil && exists != 0 { var result sql.Result result, err = tx.Exec("DELETE FROM backups WHERE id=? AND uid=? AND instance_id=?", id, i.UID, i.ID) if err == nil { n, e := result.RowsAffected() err = e indexed = int(n) } } if err != nil { fail(w, 500, "backup index unavailable") return } if statErr != nil && indexed == 0 { fail(w, 404, "backup not found") return } if _, err = tx.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "backup.delete", i.ID, id, time.Now().Unix()); err != nil { fail(w, 500, "audit unavailable") return } if statErr == nil { if err = root.Remove(name); err != nil { fail(w, 500, "backup deletion failed") return } } // Filesystem and SQLite cannot commit atomically; a retry clears a stale index after a failed commit. if err = tx.Commit(); err != nil { fail(w, 500, "backup removed; index update failed, retry deletion") return } writeJSON(w, map[string]bool{"ok": true}) }