Files
dsh-web/internal/api/files.go
T
m1saka 9a71772771 Harden image admission, resource recovery and session renewal
Add exact-image compatibility policy, experimental admission, paginated build history and shared reauthentication checks. Preserve uncertain Docker creation records, bound frontend resource waits and renew runtime sessions proactively.

Lazy-load and split Monaco, add regression and opt-in live Docker fault tests, and record full race and real-browser acceptance results.
2026-10-14 13:41:54 +08:00

826 lines
20 KiB
Go

package api
import (
"archive/zip"
"context"
"encoding/json"
"errors"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"mime"
"net/http"
"os"
"path"
"sort"
"strings"
"syscall"
"time"
"golang.org/x/sys/unix"
)
const maxUpload = 32 << 20
var uploadSlots = make(chan struct{}, 4)
const (
maxZipEntries = 10000
maxZipDepth = 64
maxZipBytes int64 = 10 << 30
zipTimeout = 2 * time.Minute
)
var zipSlots = make(chan struct{}, 4)
var errZipLimit = errors.New("archive limit exceeded")
func renameNoReplace(root *os.Root, old, next string) error {
anchor, err := root.Open(".")
if err != nil {
return err
}
defer anchor.Close()
openParent := func(p string) (int, error) {
return unix.Openat2(int(anchor.Fd()), path.Dir(p), &unix.OpenHow{
Flags: unix.O_RDONLY | unix.O_DIRECTORY | unix.O_CLOEXEC,
Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS,
})
}
src, err := openParent(old)
if err != nil {
return err
}
defer unix.Close(src)
dst, err := openParent(next)
if err != nil {
return err
}
defer unix.Close(dst)
var st unix.Stat_t
if err = unix.Fstatat(src, path.Base(old), &st, unix.AT_SYMLINK_NOFOLLOW); err != nil {
return err
}
if st.Mode&unix.S_IFMT == unix.S_IFLNK {
return errors.New("unsafe path")
}
return unix.Renameat2(src, path.Base(old), dst, path.Base(next), unix.RENAME_NOREPLACE)
}
func safeRel(p string) (string, error) {
p = strings.TrimSpace(strings.ReplaceAll(p, `\`, `/`))
if p == "" {
return ".", nil
}
if strings.HasPrefix(p, "/") {
return "", errors.New("invalid path")
}
c := path.Clean(p)
if c == ".." || strings.HasPrefix(c, "../") {
return "", errors.New("invalid path")
}
return c, nil
}
func workspacePath(uid string) string { return path.Join("workspaces", uid, "files") }
func (a *API) openWorkspace(u store.User, instanceIDs ...string) (*os.Root, error) {
instanceID := ""
if len(instanceIDs) > 0 {
instanceID = instanceIDs[0]
}
base, err := os.OpenRoot(a.root)
if err != nil {
return nil, err
}
defer base.Close()
parts := []string{"workspaces", path.Join("workspaces", u.ID), workspacePath(u.ID)}
if instanceID != "" {
rel := path.Join("workspaces", u.ID, "instances", instanceID, "home")
if err := rootSafe(base, rel, false); err != nil {
return nil, err
}
return base.OpenRoot(rel)
}
for _, p := range parts {
if err = rootSafe(base, p, true); err != nil {
return nil, err
}
if err = base.Mkdir(p, 0750); err != nil && !errors.Is(err, os.ErrExist) {
return nil, err
}
}
if err = base.Chown(workspacePath(u.ID), 1000, 1000); err != nil {
return nil, err
}
return base.OpenRoot(workspacePath(u.ID))
}
func rootSafe(root *os.Root, rel string, finalMayMissing bool) error {
if rel == "." {
return nil
}
parts := strings.Split(rel, "/")
for i := range parts {
q := strings.Join(parts[:i+1], "/")
st, err := root.Lstat(q)
if err != nil {
if finalMayMissing && i == len(parts)-1 && errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if st.Mode()&os.ModeSymlink != 0 || (i < len(parts)-1 && !st.IsDir()) {
return errors.New("unsafe path")
}
}
return nil
}
func (a *API) workspace(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/ws/download" {
var flush func()
w, flush = deferJSONResponse(w)
defer flush()
}
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
instanceID := strings.TrimSpace(r.URL.Query().Get("instance"))
if instanceID == "" && r.URL.Path != "/api/ws/usage" {
fail(w, http.StatusGone, "shared files removed; instance required")
return
}
if instanceID != "" {
// Pin this instance's home without holding the instance lifecycle lock
// across client reads/writes. Lifecycle mutations reject active leases.
inst := a.instanceLocks.Acquire(instanceID)
i, e := a.s.GetInstance(instanceID)
if e != nil || i.UID != u.ID {
inst.Release()
fail(w, 404, "instance not found")
return
}
if r.Method != http.MethodGet && (i.Status == "error" || a.upgradeRecoveryRequired(i)) {
inst.Release()
fail(w, 409, "instance requires recovery")
return
}
a.readersMu.Lock()
if a.workspaceReaders == nil {
a.workspaceReaders = make(map[string]int)
}
a.workspaceReaders[instanceID]++
a.readersMu.Unlock()
inst.Release()
defer func() {
a.readersMu.Lock()
if a.workspaceReaders != nil {
a.workspaceReaders[instanceID]--
if a.workspaceReaders[instanceID] == 0 {
delete(a.workspaceReaders, instanceID)
}
}
a.readersMu.Unlock()
}()
}
user := a.userLocks.Acquire(u.ID)
root, err := a.openWorkspace(u, instanceID)
user.Release()
if err != nil {
fail(w, 500, "storage unavailable")
return
}
defer root.Close()
switch strings.TrimPrefix(r.URL.Path, "/api/ws/") {
case "usage":
a.wsUsage(w, r, root, u)
case "tree":
a.wsTree(w, r, root, u)
case "mkdir":
a.wsMkdir(w, r, root, u)
case "rename":
a.wsRename(w, r, root, u)
case "delete":
a.wsDelete(w, r, root, u)
case "upload":
a.wsUpload(w, r, root, u)
case "download":
a.wsDownload(w, r, root)
default:
fail(w, 404, "not found")
}
}
func entry(root *os.Root, rel string) (os.FileInfo, error) {
if err := rootSafe(root, rel, false); err != nil {
return nil, err
}
return root.Lstat(rel)
}
func (a *API) wsUsage(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
if r.Method != "GET" {
fail(w, 405, "method not allowed")
return
}
user := a.userLocks.Acquire(u.ID)
n, e := a.userUsage(u)
user.Release()
if e != nil {
fail(w, 500, "usage unavailable")
return
}
writeJSON(w, map[string]int64{"used_bytes": n, "quota_bytes": u.Quota})
}
func (a *API) wsTree(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
user := a.userLocks.Acquire(u.ID)
locked := user
defer func() {
if locked != nil {
locked.Release()
}
}()
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
rel, err := safeRel(r.URL.Query().Get("path"))
if err != nil {
fail(w, 400, "invalid path")
return
}
st, err := entry(root, rel)
if err != nil || !st.IsDir() {
fail(w, 404, "not found")
return
}
es, err := readRootDir(root, rel)
if err != nil {
if errors.Is(err, errDirectoryLimit) {
fail(w, 413, "directory entry limit exceeded")
return
}
fail(w, 404, "not found")
return
}
out := make([]map[string]any, 0, len(es))
for _, de := range es {
p := de.Name()
if rel != "." {
p = rel + "/" + p
}
st, err := entry(root, p)
if err != nil || (!st.IsDir() && !st.Mode().IsRegular()) {
continue
}
sz := int64(0)
if !st.IsDir() {
sz = st.Size()
}
out = append(out, map[string]any{"name": de.Name(), "path": p, "is_dir": st.IsDir(), "size": sz, "mod_time": st.ModTime()})
}
sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
locked.Release()
locked = nil
writeJSON(w, out)
}
func (a *API) wsMkdir(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
var x struct {
Path string `json:"path"`
}
if json.NewDecoder(r.Body).Decode(&x) != nil {
fail(w, 400, "invalid json")
return
}
p, e := safeRel(x.Path)
user := a.userLocks.Acquire(u.ID)
locked := user
defer func() {
if locked != nil {
locked.Release()
}
}()
if e != nil || p == "." || rootSafe(root, path.Dir(p), false) != nil {
fail(w, 400, "cannot create directory")
return
}
if e = root.Mkdir(p, 0750); e != nil {
fail(w, 400, "cannot create directory")
return
}
if f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0); e == nil {
_ = f.Chown(1000, 1000)
f.Close()
}
locked.Release()
locked = nil
a.s.AddAudit(u.ID, "workspace.mkdir", p, "")
writeJSON(w, map[string]bool{"ok": true})
}
func (a *API) wsRename(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
var x struct {
Path string `json:"path"`
NewPath string `json:"new_path"`
}
if json.NewDecoder(r.Body).Decode(&x) != nil {
fail(w, 400, "invalid json")
return
}
p, e := safeRel(x.Path)
q, e2 := safeRel(x.NewPath)
user := a.userLocks.Acquire(u.ID)
locked := user
defer func() {
if locked != nil {
locked.Release()
}
}()
if e != nil || e2 != nil || p == "." || q == "." || rootSafe(root, p, false) != nil || rootSafe(root, path.Dir(q), false) != nil {
fail(w, 400, "rename failed")
return
}
st, e := root.Lstat(p)
if e != nil || st.Mode()&os.ModeSymlink != 0 {
fail(w, 400, "rename failed")
return
}
if _, e = root.Lstat(q); e == nil {
fail(w, 400, "rename failed")
return
}
if e = renameNoReplace(root, p, q); e != nil {
fail(w, 400, "rename failed")
return
}
locked.Release()
locked = nil
a.s.AddAudit(u.ID, "workspace.rename", p, q)
writeJSON(w, map[string]bool{"ok": true})
}
func (a *API) wsDelete(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
user := a.userLocks.Acquire(u.ID)
locked := user
defer func() {
if locked != nil {
locked.Release()
}
}()
if r.Method != http.MethodDelete {
fail(w, 405, "method not allowed")
return
}
p, e := safeRel(r.URL.Query().Get("path"))
if e != nil || p == "." {
fail(w, 400, "delete failed")
return
}
st, e := entry(root, p)
if e != nil || st.Mode()&os.ModeSymlink != 0 {
fail(w, 400, "delete failed")
return
}
if st.IsDir() {
es, e := readRootDir(root, p)
if e != nil || len(es) != 0 {
fail(w, 400, "delete failed")
return
}
}
if e = root.Remove(p); e != nil {
fail(w, 400, "delete failed")
return
}
locked.Release()
locked = nil
a.s.AddAudit(u.ID, "workspace.delete", p, "")
writeJSON(w, map[string]bool{"ok": true})
}
func (a *API) wsUpload(w http.ResponseWriter, r *http.Request, root *os.Root, u store.User) {
if r.Method != "POST" {
fail(w, 405, "method not allowed")
return
}
select {
case uploadSlots <- struct{}{}:
defer func() { <-uploadSlots }()
default:
fail(w, 429, "too many concurrent uploads")
return
}
usedBefore, usageErr := a.userUsage(u)
if usageErr != nil || usedBefore >= u.Quota {
fail(w, 413, "quota exceeded or usage unavailable")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUpload+(1<<20))
controller := http.NewResponseController(w)
if err := controller.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil && !errors.Is(err, http.ErrNotSupported) {
fail(w, 400, "upload unavailable")
return
}
defer controller.SetReadDeadline(time.Time{})
if r.ParseMultipartForm(1<<20) != nil {
fail(w, 400, "invalid upload")
return
}
defer r.MultipartForm.RemoveAll()
user := a.userLocks.Acquire(u.ID)
locked := user
defer func() {
if locked != nil {
locked.Release()
}
}()
dir, e := safeRel(r.FormValue("path"))
if e != nil || rootSafe(root, dir, false) != nil {
fail(w, 400, "invalid directory")
return
}
input, h, e := r.FormFile("file")
if e != nil {
fail(w, 400, "file required")
return
}
defer input.Close()
name, e := safeRel(h.Filename)
if e != nil || name == "." || path.Base(name) != name {
fail(w, 400, "invalid filename")
return
}
if !uploadExtensionAllowed(name, os.Getenv("UPLOAD_ALLOWED_EXTENSIONS")) {
fail(w, 415, "upload file type denied")
return
}
target := path.Join(dir, name)
if _, e = root.Lstat(target); !errors.Is(e, os.ErrNotExist) {
fail(w, 409, "target exists")
return
}
if err := a.s.DB.QueryRow("SELECT quota FROM users WHERE id=?", u.ID).Scan(&u.Quota); err != nil {
fail(w, 413, "quota unavailable")
return
}
used, e := a.userUsage(u)
if e != nil || h.Size > maxUpload || used+h.Size > u.Quota {
fail(w, 413, "quota or upload limit exceeded")
return
}
tmp := path.Join(dir, ".upload-"+store.ID())
f, e := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if e != nil {
fail(w, 400, "upload directory unavailable")
return
}
defer root.Remove(tmp)
n, e := io.Copy(f, io.LimitReader(input, maxUpload+1))
if e == nil && (n > maxUpload || used+n > u.Quota) {
e = errors.New("quota exceeded")
}
if e == nil {
e = f.Chown(1000, 1000)
}
if e == nil {
e = f.Sync()
}
f.Close()
if e == nil {
e = renameNoReplace(root, tmp, target)
}
if errors.Is(e, os.ErrExist) {
fail(w, 409, "target exists")
return
}
if e != nil {
fail(w, 400, "upload failed")
return
}
locked.Release()
locked = nil
a.s.AddAudit(u.ID, "workspace.upload", target, "")
writeJSON(w, map[string]bool{"ok": true})
}
// Empty policy permits arbitrary workspace files; configured entries are exact
// case-insensitive extensions, not MIME claims supplied by the client.
func uploadExtensionAllowed(name, policy string) bool {
if strings.TrimSpace(policy) == "" {
return true
}
ext := strings.ToLower(path.Ext(name))
for _, entry := range strings.Split(policy, ",") {
entry = strings.ToLower(strings.TrimSpace(entry))
if strings.HasPrefix(entry, ".") && len(entry) > 1 && ext == entry {
return true
}
}
return false
}
func (a *API) userUsage(u store.User) (int64, error) {
base, e := os.OpenRoot(a.root)
if e != nil {
return 0, e
}
defer base.Close()
root, e := base.OpenRoot(path.Join("workspaces", u.ID))
if e != nil {
return 0, e
}
defer root.Close()
var total int64
count := 0
var walk func(string, int) error
walk = func(p string, depth int) error {
count++
if count > 100000 || depth > 128 {
return errors.New("tree limit")
}
st, e := root.Lstat(p)
if e != nil {
return e
}
if st.Mode().IsRegular() {
total += st.Size()
return nil
}
if !st.IsDir() {
return nil
}
f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
if e != nil {
return e
}
defer f.Close()
for {
ds, readErr := f.ReadDir(128)
for _, d := range ds {
if e = walk(path.Join(p, d.Name()), depth+1); e != nil {
return e
}
}
if readErr == io.EOF {
return nil
}
if readErr != nil {
return readErr
}
if count >= 100000 {
return errors.New("tree limit")
}
}
}
e = walk(".", 0)
return total, e
}
func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root) {
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
p, e := safeRel(r.URL.Query().Get("path"))
if e != nil {
fail(w, 400, "invalid path")
return
}
anchor, e := root.Open(".")
if e != nil {
fail(w, 404, "not found")
return
}
defer anchor.Close()
f, e := openDownload(anchor, p)
if e != nil {
fail(w, 404, "not found")
return
}
defer f.Close()
st, e := f.Stat()
if e == nil && st.IsDir() {
a.wsDownloadZip(w, r, f, p)
return
}
if e != nil || !st.Mode().IsRegular() {
fail(w, 400, "invalid file")
return
}
w.Header().Set("Content-Disposition", "attachment")
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
}
func openDownload(anchor *os.File, p string) (*os.File, error) {
fd, err := unix.Openat2(int(anchor.Fd()), p, &unix.OpenHow{
Flags: unix.O_RDONLY | unix.O_NONBLOCK | unix.O_CLOEXEC,
Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV,
})
if err != nil {
return nil, err
}
f := os.NewFile(uintptr(fd), p)
st, err := f.Stat()
if err == nil && (!st.IsDir() && !st.Mode().IsRegular() || st.Mode().IsRegular() && st.Sys().(*syscall.Stat_t).Nlink != 1) {
err = errors.New("unsafe file")
}
if err != nil {
f.Close()
return nil, err
}
return f, nil
}
type zipEntry struct {
name string
info os.FileInfo
}
func zipManifest(ctx context.Context, anchor *os.File) ([]zipEntry, error) {
var entries []zipEntry
var total int64
var walk func(string, int) error
walk = func(p string, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
if depth > maxZipDepth || len(entries) >= maxZipEntries {
return errZipLimit
}
f, err := openDownload(anchor, p)
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
if p != "." {
if st.Size() < 0 || st.Mode().IsRegular() && st.Size() > maxZipBytes-total {
return errZipLimit
}
if st.Mode().IsRegular() {
total += st.Size()
}
entries = append(entries, zipEntry{p, st})
}
if !st.IsDir() {
return nil
}
for {
ds, err := f.ReadDir(128)
if err != nil && err != io.EOF {
return err
}
for _, d := range ds {
if strings.ContainsAny(d.Name(), "\\:") {
return errors.New("unsafe archive name")
}
if e := walk(path.Join(p, d.Name()), depth+1); e != nil {
return e
}
}
if err == io.EOF {
return nil
}
}
}
err := walk(".", 0)
return entries, err
}
type zipContextWriter struct {
ctx context.Context
w io.Writer
}
func (w zipContextWriter) Write(p []byte) (int, error) {
if err := w.ctx.Err(); err != nil {
return 0, err
}
return w.w.Write(p)
}
func streamZip(ctx context.Context, w io.Writer, anchor *os.File, entries []zipEntry) error {
z := zip.NewWriter(zipContextWriter{ctx, w})
buf := make([]byte, 32<<10)
for _, item := range entries {
if err := ctx.Err(); err != nil {
return err
}
f, err := openDownload(anchor, item.name)
if err != nil {
return err
}
err = func() error {
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
if !os.SameFile(st, item.info) || st.Mode() != item.info.Mode() || st.Size() != item.info.Size() || !st.ModTime().Equal(item.info.ModTime()) {
return errors.New("file changed")
}
h, err := zip.FileInfoHeader(st)
if err != nil {
return err
}
h.Name = item.name
if st.IsDir() {
h.Name += "/"
}
dst, err := z.CreateHeader(h)
if err != nil || st.IsDir() {
return err
}
n, err := io.CopyBuffer(dst, io.LimitReader(f, item.info.Size()), buf)
if err == nil && n != item.info.Size() {
err = io.ErrUnexpectedEOF
}
if err == nil {
after, statErr := f.Stat()
if statErr != nil {
return statErr
}
if after.Size() != st.Size() || !after.ModTime().Equal(st.ModTime()) {
return errors.New("file changed")
}
}
return err
}()
if err != nil {
return err
}
}
return z.Close()
}
func (a *API) wsDownloadZip(w http.ResponseWriter, r *http.Request, anchor *os.File, p string) {
select {
case zipSlots <- struct{}{}:
defer func() { <-zipSlots }()
default:
fail(w, 429, "too many archive downloads")
return
}
ctx, cancel := context.WithTimeout(r.Context(), zipTimeout)
defer cancel()
controller := http.NewResponseController(w)
if err := controller.SetWriteDeadline(time.Now().Add(zipTimeout)); err != nil && !errors.Is(err, http.ErrNotSupported) {
fail(w, 500, "download unavailable")
return
}
defer controller.SetWriteDeadline(time.Time{})
entries, err := zipManifest(ctx, anchor)
if err != nil {
code := 400
if errors.Is(err, errZipLimit) {
code = 413
}
fail(w, code, "cannot archive directory")
return
}
name := path.Base(p)
if p == "." {
name = "workspace"
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name + ".zip"}))
if err := streamZip(ctx, w, anchor, entries); err != nil {
// A failed stream must not become a successful, silently incomplete archive.
panic(http.ErrAbortHandler)
}
}
const maxDirectoryEntries = 10000
var errDirectoryLimit = errors.New("directory entry limit exceeded")
func readRootDir(root *os.Root, p string) ([]os.DirEntry, error) {
f, e := root.OpenFile(p, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0)
if e != nil {
return nil, e
}
defer f.Close()
entries, err := f.ReadDir(maxDirectoryEntries + 1)
if len(entries) > maxDirectoryEntries {
return nil, errDirectoryLimit
}
if err == io.EOF {
err = nil
}
return entries, err
}