diff --git a/cmd/felis/api.go b/cmd/felis/api.go index aae54b0..1ce4be8 100644 --- a/cmd/felis/api.go +++ b/cmd/felis/api.go @@ -491,6 +491,9 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int { go pruner.Loop(ctx, registryPruneInterval) } go reapRejectedContexts(ctx, submissions, stderr) + if fileStage != nil { + go expireFileSessions(ctx, fileStage, fileSessionSweep, stderr) + } go retention.Loop(ctx, drv.DB(), retention.Policy{Audit: auditRetention}, retentionInterval, slog.Default()) servers := []*http.Server{internalSrv, externalSrv} @@ -797,6 +800,30 @@ func scheduleBackups(ctx context.Context, s *api.BackupScheduler, stderr io.Writ } } +// fileSessionSweep is how often expireFileSessions looks for idle upload +// sessions: small beside fileedit.SessionIdle, so an abandoned one gives its +// room back within minutes of going stale. +const fileSessionSweep = 10 * time.Minute + +// expireFileSessions drops the file manager's upload sessions left untouched +// for fileedit.SessionIdle. Each reserved room on the staging disk for its whole +// file when it began, so one abandoned would otherwise hold that room until +// felis-api restarts. +func expireFileSessions(ctx context.Context, s *fileedit.Stage, every time.Duration, stderr io.Writer) { + t := time.NewTicker(every) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if n := s.Expire(); n > 0 { + fmt.Fprintf(stderr, "felis api: dropped %d upload session(s) left idle for %s\n", n, fileedit.SessionIdle) + } + } + } +} + // reapRejectedContexts deletes, once an hour, the uploaded contexts of // submissions rejected more than submit.RejectedContextRetention ago, and the // chunked uploads left untouched for submit.StalePartRetention. Without it a diff --git a/cmd/felis/api_test.go b/cmd/felis/api_test.go index 7b20b26..59895d7 100644 --- a/cmd/felis/api_test.go +++ b/cmd/felis/api_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "errors" "fmt" @@ -8,6 +9,7 @@ import ( "net" "net/http" "slices" + "sync" "sync/atomic" "testing" "time" @@ -15,6 +17,7 @@ import ( "felis.lolicon.best/internal/api" "felis.lolicon.best/internal/build" "felis.lolicon.best/internal/config" + "felis.lolicon.best/internal/fileedit" ) // The passkey relying party follows the panel host the SPA is served on: an install @@ -235,3 +238,51 @@ func TestInUseImageRefsCoversEverySource(t *testing.T) { t.Fatal("a failing whitelist read produced a reference list") } } + +// TestExpireFileSessions runs the loop against a stage whose clock the test +// holds: the session idle past fileedit.SessionIdle goes, the one touched since +// stays, and the drop is said once. +func TestExpireFileSessions(t *testing.T) { + var mu sync.Mutex + now := time.Date(2026, 9, 28, 10, 0, 0, 0, time.UTC) + advance := func(d time.Duration) { mu.Lock(); now = now.Add(d); mu.Unlock() } + st := &fileedit.Stage{Dir: t.TempDir(), MinFree: 1e-9, Now: func() time.Time { + mu.Lock() + defer mu.Unlock() + return now + }} + idle, err := st.Begin("u1", "survival", "a.jar", 3) + if err != nil { + t.Fatal(err) + } + advance(fileedit.SessionIdle - time.Minute) + fresh, err := st.Begin("u1", "survival", "b.jar", 3) + if err != nil { + t.Fatal(err) + } + advance(2 * time.Minute) + + ctx, cancel := context.WithCancel(context.Background()) + var out bytes.Buffer + done := make(chan struct{}) + go func() { expireFileSessions(ctx, st, time.Millisecond, &out); close(done) }() + for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(time.Millisecond) { + if _, err := st.Status("u1", "survival", idle.ID); errors.Is(err, fileedit.ErrNotStaged) { + break + } + if time.Now().After(deadline) { + cancel() + t.Fatal("the idle session was never dropped") + } + } + // A few more ticks with nothing idle, which must stay quiet. + time.Sleep(20 * time.Millisecond) + cancel() + <-done + if _, err := st.Status("u1", "survival", fresh.ID); err != nil { + t.Fatalf("the session touched since went too: %v", err) + } + if got := out.String(); got != "felis api: dropped 1 upload session(s) left idle for 6h0m0s\n" { + t.Fatalf("said %q", got) + } +} diff --git a/cmd/felis/export.go b/cmd/felis/export.go index a90ee12..398ced4 100644 --- a/cmd/felis/export.go +++ b/cmd/felis/export.go @@ -2,26 +2,33 @@ package main import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "flag" "fmt" + "hash" "io" + "io/fs" "net/http" "os" "os/signal" + "strings" "syscall" "time" "felis.lolicon.best/internal/backup" + "felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/worldexport" ) // cmdExport is the in-Pod entrypoint the export Job runs. internal/worldexport // renders a Pod whose command is `/usr/local/bin/felis export`. It archives the -// mounted world (or opens one archive on the mounted backup store), PUTs the -// tar.gz to felis-api's internal face, and exits once felis-api says the -// owner's browser got all of it. It is NOT a user-facing command and is never -// invoked by hand. +// mounted world, re-streams one archive from the mounted backup store, or sends +// one file or folder of the world, PUTs it to felis-api's internal face, and +// exits once felis-api says the owner's browser got all of it. It is NOT a +// user-facing command and is never invoked by hand. // // Like cmdRestore it holds no database credentials and never calls config.Load: // felis-api made every decision (who may download what, that the server is @@ -29,19 +36,29 @@ import ( // plus the one-time upload token in the environment, which opens this one // export and nothing else. // +// Whatever leaves goes through the same guards as the file editor +// (fileedit.Guard): the proxy forwarding secret, which every server on the +// install shares, never leaves, and server.properties leaves with its RCON +// password redacted. A backup is stored with both, since a restore must bring +// the world back whole, so it is filtered on the way out rather than handed +// over as stored. +// // Exit status: 0 once felis-api answers 204 (the download completed), 1 when -// the archive could not be read or handed over, or felis-api refused it (the -// browser never came, left early, or the backup failed its digest check), 2 on +// the export could not be read or handed over, a backup failed its digest +// check, or felis-api refused it (the browser never came or left early), 2 on // bad flags. The last stderr line reaches the export's status and the jobs list. func cmdExport(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("export", flag.ContinueOnError) fs.SetOutput(stderr) - mode := fs.String("mode", "", "what to export: world or backup") + mode := fs.String("mode", "", "what to export: world, backup or files") server := fs.String("server", "", "server name being exported (for logging)") - target := fs.String("target-url", "", "felis-api URL to PUT the archive to") + target := fs.String("target-url", "", "felis-api URL to PUT the export to") ref := fs.String("ref", "", "backup only: absolute path to the archive on the backup mount") backupRoot := fs.String("backup-root", "/backups", "backup only: mount path of the backup PVC (the ref must resolve under it)") - worldsRoot := fs.String("worlds-root", "/world", "world only: mount path of the world PVC to archive") + sum := fs.String("sha256", "", "backup only: the sha256 recorded when the archive was written; a mismatch fails the export before its end is sent") + worldsRoot := fs.String("worlds-root", "/world", "world and files: mount path of the world PVC") + path := fs.String("path", "", "files only: the file or folder to send, relative to the world root") + dir := fs.Bool("dir", false, "files only: the path is a folder, sent as a zip") if err := fs.Parse(args); err != nil { return 2 } @@ -60,11 +77,17 @@ func cmdExport(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "felis export: --ref is required for a backup") return 2 } - err = exportBackup(ctx, *target, token, *ref, *backupRoot) + err = exportBackup(ctx, *target, token, *ref, *backupRoot, *sum, stdout) case worldexport.ModeWorld: err = exportWorld(ctx, *target, token, *worldsRoot, stdout) + case worldexport.ModeFiles: + if *path == "" { + fmt.Fprintln(stderr, "felis export: --path is required for files") + return 2 + } + err = exportFiles(ctx, *target, token, *worldsRoot, *path, *dir, stdout) default: - fmt.Fprintf(stderr, "felis export: --mode must be %s or %s\n", worldexport.ModeWorld, worldexport.ModeBackup) + fmt.Fprintf(stderr, "felis export: --mode must be %s, %s or %s\n", worldexport.ModeWorld, worldexport.ModeBackup, worldexport.ModeFiles) return 2 } if err != nil { @@ -75,9 +98,20 @@ func cmdExport(args []string, stdout, stderr io.Writer) int { return 0 } -// exportBackup hands over one stored archive as it is, with its length, so the -// browser shows real progress and felis-api can check its recorded digest. -func exportBackup(ctx context.Context, target, token, ref, root string) error { +// archiveType is the content type of a world or backup export. +const archiveType = "application/gzip" + +// errBackupDigest fails a backup export whose stored archive no longer hashes +// to what was recorded when it was written. +var errBackupDigest = errors.New("the backup archive does not match the sha256 recorded when it was written") + +// exportBackup re-streams one stored archive through the export guards +// (backup.FilterTarGz with archiveFilter). Its length changes on the way, so it +// goes chunked. With want set, the stored bytes are hashed as they are read, +// and FilterTarGz reads them to their end before it closes its own archive: a +// mismatch aborts the upload while what felis-api has passed on still lacks +// its end, so the browser never keeps a complete-looking corrupt file. +func exportBackup(ctx context.Context, target, token, ref, root, want string, stdout io.Writer) error { // Defense in depth, as in cmdRestore: the ref comes from felis-api, but this // process opens it, so it confirms the ref stays on the backup mount. if !refWithinRoot(ref, root) { @@ -88,47 +122,140 @@ func exportBackup(ctx context.Context, target, token, ref, root string) error { return err } defer f.Close() - st, err := f.Stat() - if err != nil { - return err + var src io.Reader = f + if want != "" { + src = &digestReader{r: f, sum: sha256.New(), want: want} } - return putExport(ctx, target, token, f, st.Size()) + var withheld []string + err = streamExport(ctx, target, token, archiveType, -1, func(w io.Writer) error { + var err error + withheld, err = backup.FilterTarGz(ctx, w, src, archiveFilter) + return err + }) + if errors.Is(err, errBackupDigest) { + return errBackupDigest // the jobs list shows it as it is, not wrapped as a read error + } + reportWithheld(stdout, len(withheld)) + return err } // exportWorld archives the world straight into the request body: nothing is // staged, so a world bigger than the Pod's memory or any scratch disk exports -// the same. A read error mid-way aborts the chunked body, and felis-api cuts -// the browser's download off rather than end it. +// the same. func exportWorld(ctx context.Context, target, token, root string, stdout io.Writer) error { - pr, pw := io.Pipe() - skippedc := make(chan []string, 1) - go func() { - skipped, err := backup.WriteTarGz(ctx, pw, root) - pw.CloseWithError(err) - skippedc <- skipped - }() - err := putExport(ctx, target, token, pr, -1) - pr.CloseWithError(io.ErrClosedPipe) // stops the archiver if the PUT ended first - if skipped := <-skippedc; len(skipped) > 0 { + r, err := os.OpenRoot(root) + if err != nil { + return err + } + guard := fileedit.NewGuard(r) + r.Close() + var skipped, withheld []string + err = streamExport(ctx, target, token, archiveType, -1, func(w io.Writer) error { + var err error + skipped, withheld, err = backup.WriteTarGz(ctx, w, root, worldFilter(guard)) + return err + }) + if len(skipped) > 0 { fmt.Fprintf(stdout, "felis export: left out %d entries a tar cannot hold (symbolic links, devices, sockets)\n", len(skipped)) } + reportWithheld(stdout, len(withheld)) + return err +} + +// exportFiles sends one file or folder of the world (fileedit.OpenDownload): a +// file with its exact length, a folder as a zip made as it streams. dir is +// what the owner saw at path when they asked. +func exportFiles(ctx context.Context, target, token, root, path string, dir bool, stdout io.Writer) error { + d, err := fileedit.OpenDownload(root, path, dir) + if err != nil { + return err + } + defer d.Close() + err = streamExport(ctx, target, token, d.ContentType, d.Size, func(w io.Writer) error { return d.WriteTo(ctx, w) }) + if d.Skipped > 0 { + fmt.Fprintf(stdout, "felis export: left out %d entries a zip does not carry (symbolic links, devices, sockets)\n", d.Skipped) + } + reportWithheld(stdout, d.Withheld) + return err +} + +func reportWithheld(stdout io.Writer, n int) { + if n > 0 { + fmt.Fprintf(stdout, "felis export: left out %d files that hold platform secrets\n", n) + } +} + +// worldFilter guards a live world by file identity, so a link to a guarded +// file under another name is caught as well. +func worldFilter(g fileedit.Guard) backup.Filter { + return func(_ string, info fs.FileInfo) (bool, func([]byte) []byte) { + return guardAction(g.Rule(info)) + } +} + +// archiveFilter guards a stored archive, which has only names. +func archiveFilter(name string, _ fs.FileInfo) (bool, func([]byte) []byte) { + return guardAction(fileedit.ArchiveRule(name)) +} + +func guardAction(withhold, redact bool) (bool, func([]byte) []byte) { + if redact { + return withhold, fileedit.RedactProps + } + return withhold, nil +} + +// digestReader passes r through, hashing it, and turns r's EOF into +// errBackupDigest when the bytes do not hash to want. +type digestReader struct { + r io.Reader + sum hash.Hash + want string +} + +func (d *digestReader) Read(p []byte) (int, error) { + n, err := d.r.Read(p) + d.sum.Write(p[:n]) + if err == io.EOF && !strings.EqualFold(hex.EncodeToString(d.sum.Sum(nil)), d.want) { + return n, errBackupDigest + } + return n, err +} + +// streamExport runs write straight into the body of the PUT. An error from +// write aborts the chunked body, and felis-api then cuts the browser's download +// off rather than end it; that error is the one reported, since the PUT's own +// error only wraps it. When the PUT ends first, write is stopped. +func streamExport(ctx context.Context, target, token, contentType string, size int64, write func(io.Writer) error) error { + pr, pw := io.Pipe() + werr := make(chan error, 1) + go func() { + err := write(pw) + pw.CloseWithError(err) + werr <- err + }() + err := putExport(ctx, target, token, contentType, pr, size) + pr.CloseWithError(io.ErrClosedPipe) + if w := <-werr; w != nil && !errors.Is(w, io.ErrClosedPipe) { + return w + } return err } -// putExport PUTs the archive to felis-api. There is no retry: the token opens +// putExport PUTs the export to felis-api. There is no retry: the token opens // the export once, so a second attempt could only be refused. Redirects are // refused because the request carries the token and the internal face never // redirects. felis-api answers only after the whole download, which the Job's // activeDeadlineSeconds bounds, so the header timeout is a backstop for a // wedged endpoint and not the real limit. -func putExport(ctx context.Context, target, token string, body io.Reader, size int64) error { +func putExport(ctx context.Context, target, token, contentType string, body io.Reader, size int64) error { req, err := http.NewRequestWithContext(ctx, http.MethodPut, target, body) if err != nil { return err } req.ContentLength = size req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/gzip") + req.Header.Set("Content-Type", contentType) client := &http.Client{ Transport: &http.Transport{ResponseHeaderTimeout: 10 * time.Minute}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, diff --git a/cmd/felis/export_test.go b/cmd/felis/export_test.go index 37157c7..b05cd5e 100644 --- a/cmd/felis/export_test.go +++ b/cmd/felis/export_test.go @@ -2,13 +2,21 @@ package main import ( "archive/tar" + "archive/zip" "bytes" "compress/gzip" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" + "reflect" + "slices" "strconv" "strings" "sync/atomic" @@ -67,15 +75,51 @@ func tarEntries(t *testing.T, archive []byte) map[string]string { } } -func TestCmdExportWorld(t *testing.T) { - root := t.TempDir() - if err := os.MkdirAll(filepath.Join(root, "world", "region"), 0o755); err != nil { - t.Fatal(err) - } - for name, body := range map[string]string{"server.properties": "motd=hi\n", "world/region/r.0.0.mca": "chunks"} { - if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o600); err != nil { +// writeTree writes name → body under root, making the folders on the way. +func writeTree(t *testing.T, root string, files map[string]string) { + t.Helper() + for name, body := range files { + p := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } +} + +// The two secrets a world holds, and what server.properties reads as once +// redacted. +const ( + secretProps = "motd=hi\nrcon.password=hunter2\n" + redactedProps = "motd=hi\nrcon.password=\n" + forwardingKey = "secret: aVeryRealForwardingKey\n" +) + +// secretWorld is a world holding both secrets, with a hard link to the +// forwarding secret under a name nothing would guard by. +func secretWorld(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeTree(t, root, map[string]string{ + "server.properties": secretProps, + "config/paper-global.yml": forwardingKey, + "world/region/r.0.0.mca": "chunks", + }) + if err := os.MkdirAll(filepath.Join(root, "plugins"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Link(filepath.Join(root, "config/paper-global.yml"), filepath.Join(root, "plugins/copy.yml")); err != nil { + t.Fatal(err) + } + return root +} + +func TestCmdExportWorld(t *testing.T) { + root := secretWorld(t) + if err := os.Symlink("server.properties", filepath.Join(root, "props-link")); err != nil { + t.Fatal(err) } rcv := receiveExport(t, noContent) t.Setenv(worldexport.TokenEnv, "tok") @@ -90,61 +134,202 @@ func TestCmdExportWorld(t *testing.T) { r.Header.Get("Content-Type") != "application/gzip" || r.ContentLength != -1 || strings.Join(r.TransferEncoding, ",") != "chunked" { t.Fatalf("request = %s %s, headers %v, length %d, encoding %v", r.Method, r.URL.Path, r.Header, r.ContentLength, r.TransferEncoding) } - got := tarEntries(t, rcv.body) - want := map[string]string{"server.properties": "motd=hi\n", "world/": "", "world/region/": "", "world/region/r.0.0.mca": "chunks"} - if len(got) != len(want) { - t.Fatalf("archive holds %v, want %v", got, want) + want := map[string]string{ + "server.properties": redactedProps, "config/": "", "plugins/": "", + "world/": "", "world/region/": "", "world/region/r.0.0.mca": "chunks", } - for name, body := range want { - if b, ok := got[name]; !ok || b != body { - t.Errorf("%s = %q (present %v), want %q", name, b, ok, body) - } + if got := tarEntries(t, rcv.body); !reflect.DeepEqual(got, want) { + t.Fatalf("archive holds %v\nwant %v", got, want) } - if !strings.Contains(stdout.String(), "server=survival mode=world downloaded") { - t.Errorf("stdout = %q", stdout.String()) + want2 := "felis export: left out 1 entries a tar cannot hold (symbolic links, devices, sockets)\n" + + "felis export: left out 2 files that hold platform secrets\n" + + "felis export: server=survival mode=world downloaded\n" + if stdout.String() != want2 { + t.Errorf("stdout = %q, want %q", stdout.String(), want2) } } -// A world that cannot be read must never reach felis-api as a complete body: -// the chunked upload is cut off, so felis-api aborts the browser's download. -func TestCmdExportWorldReadErrorAbortsTheUpload(t *testing.T) { +// A world root that cannot be opened fails before anything reaches felis-api. +func TestCmdExportWorldUnreadable(t *testing.T) { rcv := receiveExport(t, noContent) t.Setenv(worldexport.TokenEnv, "tok") var stdout, stderr bytes.Buffer code := cmdExport([]string{"--mode", "world", "--target-url", rcv.srv.URL, "--worlds-root", filepath.Join(t.TempDir(), "missing")}, &stdout, &stderr) - if code != 1 { - t.Fatalf("exit %d, want 1", code) + if code != 1 || rcv.hits.Load() != 0 { + t.Fatalf("exit %d with %d requests, want 1 and none", code, rcv.hits.Load()) + } +} + +// An export that fails part-way must never reach felis-api as a complete body: +// the chunked upload is cut off, so felis-api aborts the browser's download, +// and the failure itself is what the Job reports. +func TestStreamExportWriteErrorAbortsTheUpload(t *testing.T) { + broken := errors.New("disk read failed") + rcv := receiveExport(t, noContent) + err := streamExport(context.Background(), rcv.srv.URL, "tok", "application/gzip", -1, func(w io.Writer) error { + if _, err := w.Write(bytes.Repeat([]byte("x"), 100_000)); err != nil { + return err + } + return broken + }) + if err != broken { + t.Fatalf("err = %v, want the write's own error, unwrapped", err) } select { case <-rcv.served: if rcv.readErr == nil { - t.Fatalf("felis-api read a complete %d-byte body from an unreadable world", len(rcv.body)) + t.Fatalf("felis-api read a complete %d-byte body from a failed export", len(rcv.body)) } - case <-time.After(2 * time.Second): // the request never reached the handler + case <-time.After(5 * time.Second): + t.Fatal("the request never reached felis-api") } } +// When felis-api refuses first, its reason is reported, not the closed pipe +// that then stops the writer. +func TestStreamExportRefusalStopsTheWriter(t *testing.T) { + refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer refusing.Close() + stopped := make(chan error, 1) + err := streamExport(context.Background(), refusing.URL, "tok", "application/gzip", -1, func(w io.Writer) error { + for { + if _, err := w.Write(make([]byte, 32<<10)); err != nil { + stopped <- err + return err + } + } + }) + if err == nil || err.Error() != "felis-api answered 404 Not Found" { + t.Fatalf("err = %v, want felis-api's answer", err) + } + if werr := <-stopped; !errors.Is(werr, io.ErrClosedPipe) { + t.Fatalf("the writer stopped on %v, want the closed pipe", werr) + } + + // A PUT that never starts leaves no transport to close the body: the writer + // is still stopped, and the export fails rather than hangs. + done := make(chan error, 1) + go func() { + done <- streamExport(context.Background(), "http://[::1", "tok", "application/gzip", -1, func(w io.Writer) error { + _, err := w.Write([]byte("x")) + return err + }) + }() + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), "missing ']'") { + t.Fatalf("err = %v, want the bad URL", err) + } + case <-time.After(5 * time.Second): + t.Fatal("an export whose PUT never started hung") + } +} + +// storedBackup writes, at path, a gzip+tar like one the backup store holds: +// the world whole, both secrets included, and a region file that does not +// compress. It returns the archive's sha256. +func storedBackup(t *testing.T, path string) string { + t.Helper() + region := make([]byte, 64<<10) + if _, err := rand.Read(region); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + tw := tar.NewWriter(zw) + for _, e := range []struct{ name, body string }{ + {"server.properties", secretProps}, + {"config/paper-global.yml", forwardingKey}, + {"world/level.dat", "level"}, + {"world/region/r.0.0.mca", string(region)}, + } { + if err := tw.WriteHeader(&tar.Header{Name: e.name, Typeflag: tar.TypeReg, Mode: 0o600, Size: int64(len(e.body))}); err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(tw, e.body); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(buf.Bytes()) + return hex.EncodeToString(sum[:]) +} + func TestCmdExportBackup(t *testing.T) { root := t.TempDir() - archive := bytes.Repeat([]byte("felis"), 10_000) ref := filepath.Join(root, "survival-1.tar.gz") - if err := os.WriteFile(ref, archive, 0o600); err != nil { + sum := storedBackup(t, ref) + stored, err := os.ReadFile(ref) + if err != nil { t.Fatal(err) } + region := tarEntries(t, stored)["world/region/r.0.0.mca"] args := func(url, ref string) []string { return []string{"--mode", "backup", "--server", "survival", "--target-url", url, "--ref", ref, "--backup-root", root} } - t.Run("hands the archive over with its length", func(t *testing.T) { + for name, extra := range map[string][]string{ + "no digest recorded": nil, + "recorded digest matches": {"--sha256", sum}, + } { + t.Run(name+": re-streamed through the guards", func(t *testing.T) { + rcv := receiveExport(t, noContent) + t.Setenv(worldexport.TokenEnv, "tok") + var stdout, stderr bytes.Buffer + if code := cmdExport(append(args(rcv.srv.URL, ref), extra...), &stdout, &stderr); code != 0 { + t.Fatalf("exit %d, stderr %q", code, stderr.String()) + } + r := rcv.req + if r.ContentLength != -1 || r.Header.Get("Content-Type") != "application/gzip" || r.Header.Get("Authorization") != "Bearer tok" { + t.Fatalf("length %d, headers %v", r.ContentLength, r.Header) + } + want := map[string]string{"server.properties": redactedProps, "world/level.dat": "level", "world/region/r.0.0.mca": region} + if got := tarEntries(t, rcv.body); !reflect.DeepEqual(got, want) { + t.Fatalf("archive holds %d entries, want exactly the redacted properties, level.dat and the region file", len(got)) + } + if want := "felis export: left out 1 files that hold platform secrets\nfelis export: server=survival mode=backup downloaded\n"; stdout.String() != want { + t.Errorf("stdout = %q, want %q", stdout.String(), want) + } + }) + } + + // The stored bytes are checked as they stream, and the archive the Job sends + // is only closed once they are all read: a mismatch cuts the upload off + // short of its end, so felis-api never passes on a complete-looking copy. + t.Run("a digest mismatch cuts the upload off before its end", func(t *testing.T) { rcv := receiveExport(t, noContent) t.Setenv(worldexport.TokenEnv, "tok") var stdout, stderr bytes.Buffer - if code := cmdExport(args(rcv.srv.URL, ref), &stdout, &stderr); code != 0 { - t.Fatalf("exit %d, stderr %q", code, stderr.String()) + if code := cmdExport(append(args(rcv.srv.URL, ref), "--sha256", strings.Repeat("ab", 32)), &stdout, &stderr); code != 1 { + t.Fatalf("exit %d, want 1", code) } - if rcv.req.ContentLength != int64(len(archive)) || !bytes.Equal(rcv.body, archive) || rcv.req.Header.Get("Authorization") != "Bearer tok" { - t.Fatalf("got %d bytes (length %d, auth %q), want the %d archive bytes", - len(rcv.body), rcv.req.ContentLength, rcv.req.Header.Get("Authorization"), len(archive)) + if want := "felis export: the backup archive does not match the sha256 recorded when it was written\n"; stderr.String() != want { + t.Fatalf("stderr = %q, want %q", stderr.String(), want) + } + select { + case <-rcv.served: + case <-time.After(5 * time.Second): + t.Fatal("the upload never reached felis-api") + } + if rcv.readErr == nil { + t.Fatalf("felis-api read a complete %d-byte body", len(rcv.body)) + } + zr, err := gzip.NewReader(bytes.NewReader(rcv.body)) + if err == nil { + _, err = io.ReadAll(zr) + } + if err == nil { + t.Fatal("what felis-api got is a complete archive") } }) @@ -204,6 +389,86 @@ func TestCmdExportBackup(t *testing.T) { } } +func TestCmdExportFiles(t *testing.T) { + root := secretWorld(t) + writeTree(t, root, map[string]string{"plugins/Essentials/config.yml": "x: 1"}) + if err := os.Symlink("config.yml", filepath.Join(root, "plugins/Essentials/link.yml")); err != nil { + t.Fatal(err) + } + export := func(t *testing.T, path string, dir bool) (*exportReceiver, int, string, string) { + t.Helper() + rcv := receiveExport(t, noContent) + t.Setenv(worldexport.TokenEnv, "tok") + args := []string{"--mode", "files", "--server", "survival", "--target-url", rcv.srv.URL, "--worlds-root", root, "--path", path} + if dir { + args = append(args, "--dir") + } + var stdout, stderr bytes.Buffer + code := cmdExport(args, &stdout, &stderr) + return rcv, code, stdout.String(), stderr.String() + } + + for path, want := range map[string]string{ + "world/region/r.0.0.mca": "chunks", + "server.properties": redactedProps, + } { + t.Run("a file goes with its exact length: "+path, func(t *testing.T) { + rcv, code, stdout, stderr := export(t, path, false) + if code != 0 || stdout != "felis export: server=survival mode=files downloaded\n" { + t.Fatalf("exit %d, stdout %q, stderr %q", code, stdout, stderr) + } + if string(rcv.body) != want || rcv.req.ContentLength != int64(len(want)) || rcv.req.Header.Get("Content-Type") != "application/octet-stream" { + t.Fatalf("body %q, length %d, type %q; want %q", rcv.body, rcv.req.ContentLength, rcv.req.Header.Get("Content-Type"), want) + } + }) + } + + t.Run("a folder goes as a zip, guarded", func(t *testing.T) { + rcv, code, stdout, stderr := export(t, "plugins", true) + if code != 0 { + t.Fatalf("exit %d, stderr %q", code, stderr) + } + if rcv.req.ContentLength != -1 || rcv.req.Header.Get("Content-Type") != "application/zip" { + t.Fatalf("length %d, type %q", rcv.req.ContentLength, rcv.req.Header.Get("Content-Type")) + } + zr, err := zip.NewReader(bytes.NewReader(rcv.body), int64(len(rcv.body))) + if err != nil { + t.Fatal(err) + } + var names []string + for _, f := range zr.File { + names = append(names, f.Name) + } + if want := []string{"plugins/", "plugins/Essentials/", "plugins/Essentials/config.yml"}; !slices.Equal(names, want) { + t.Fatalf("zip holds %v, want %v", names, want) + } + want := "felis export: left out 1 entries a zip does not carry (symbolic links, devices, sockets)\n" + + "felis export: left out 1 files that hold platform secrets\n" + + "felis export: server=survival mode=files downloaded\n" + if stdout != want { + t.Errorf("stdout = %q, want %q", stdout, want) + } + }) + + for _, c := range []struct { + path string + dir bool + want string + }{ + {"config/paper-global.yml", false, "forwarding secret"}, + {"plugins/copy.yml", false, "forwarding secret"}, + {"plugins", false, "is a folder now"}, + {"server.properties", true, "is not a folder now"}, + } { + t.Run("refused before any request: "+c.path, func(t *testing.T) { + rcv, code, _, stderr := export(t, c.path, c.dir) + if code != 1 || rcv.hits.Load() != 0 || !strings.Contains(stderr, c.want) { + t.Fatalf("exit %d, %d requests, stderr %q; want 1, none, and %q", code, rcv.hits.Load(), stderr, c.want) + } + }) + } +} + func TestCmdExportUsage(t *testing.T) { for name, tc := range map[string]struct { token string @@ -213,6 +478,7 @@ func TestCmdExportUsage(t *testing.T) { "no target": {"tok", []string{"--mode", "world"}}, "unknown mode": {"tok", []string{"--mode", "both", "--target-url", "http://api/x"}}, "backup without ref": {"tok", []string{"--mode", "backup", "--target-url", "http://api/x"}}, + "files without path": {"tok", []string{"--mode", "files", "--target-url", "http://api/x"}}, } { t.Run(name, func(t *testing.T) { t.Setenv(worldexport.TokenEnv, tc.token) diff --git a/cmd/felis/files.go b/cmd/felis/files.go index 8358d3b..1e7b11b 100644 --- a/cmd/felis/files.go +++ b/cmd/felis/files.go @@ -39,7 +39,7 @@ import ( func cmdFiles(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("files", flag.ContinueOnError) fs.SetOutput(stderr) - op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename or upload") + op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename, upload or unzip") path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)") worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it") expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this") @@ -48,7 +48,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { sourceURL := fs.String("source-url", "", "upload only: felis-api URL to fetch the bytes from") size := fs.Int64("size", -1, "upload only: the byte count the fetched file must have") sum := fs.String("sha256", "", "upload only: the SHA-256 (hex) the fetched file must have") - overwrite := fs.Bool("overwrite", false, "upload only: replace a file already at the path") + overwrite := fs.Bool("overwrite", false, "upload and unzip: replace files already there") if err := fs.Parse(args); err != nil { return 2 } @@ -88,6 +88,10 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { Open: func() (io.ReadCloser, error) { return fetchUpload(ctx, *sourceURL, token) }, } } + // An upload or an unzip (the only ops that report progress) can run long + // enough that felis-api does not wait on its Job, and the panel shows how far + // it has got from the latest of these lines (fileedit.K8sRunner.Ops). + req.Progress = fileedit.ThrottledProgress(stdout, time.Second, time.Now) res, err := fileedit.Execute(*worldsRoot, req) if err != nil { @@ -107,8 +111,9 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { // fetchUpload opens the staged upload on felis-api's internal face. There is no // retry: the token opens the upload once (fileedit.Stage), so a second attempt -// could only be refused, and felis-api answers the failed Job with a 500 the -// caller can retry whole. Redirects are refused because the request carries the +// could only be refused, and the caller retries the failed Job whole (a file +// sent in parts stays staged until it has been served whole once, so that retry +// does not send it again). Redirects are refused because the request carries the // token and the internal face never redirects; the header timeout catches a // wedged endpoint, and the Job's activeDeadlineSeconds bounds the body. func fetchUpload(ctx context.Context, url, token string) (io.ReadCloser, error) { diff --git a/cmd/felis/files_test.go b/cmd/felis/files_test.go index b392339..8c76054 100644 --- a/cmd/felis/files_test.go +++ b/cmd/felis/files_test.go @@ -1,11 +1,13 @@ package main import ( + "archive/zip" "bytes" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" + "io" "net/http" "net/http/httptest" "os" @@ -17,10 +19,12 @@ import ( "felis.lolicon.best/internal/fileedit" ) -// filesResult is the Result a `felis files` run printed on its marked line. +// filesResult is the Result a `felis files` run printed on its marked line, +// the last it prints. func filesResult(t *testing.T, stdout string) fileedit.Result { t.Helper() - line, ok := strings.CutPrefix(strings.TrimSpace(stdout), fileedit.ResultPrefix) + lines := strings.Split(strings.TrimSpace(stdout), "\n") + line, ok := strings.CutPrefix(lines[len(lines)-1], fileedit.ResultPrefix) if !ok { t.Fatalf("stdout has no result line: %q", stdout) } @@ -75,6 +79,9 @@ func TestCmdFilesUpload(t *testing.T) { if code := cmdFiles(uploadArgs(root, srv.URL+"/u", body), &stdout, &stderr); code != 0 { t.Fatalf("exit %d, stderr %q", code, stderr.String()) } + if !strings.HasPrefix(stdout.String(), fileedit.ProgressPrefix+`{"done":4,"total":4}`+"\n") { + t.Fatalf("stdout %q does not start with the progress to the last byte", stdout.String()) + } if res := filesResult(t, stdout.String()); res.Code != "" { t.Fatalf("result = %+v", res) } @@ -201,3 +208,43 @@ func TestCmdFilesCallerFaultIsAResult(t *testing.T) { t.Fatalf("no --op: exit %d, want 2", code) } } + +// TestCmdFilesUnzip checks an unzip extracts next to the archive and reports its +// progress before its result, the same way an upload does. +func TestCmdFilesUnzip(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "maps"), 0o755); err != nil { + t.Fatal(err) + } + var zb bytes.Buffer + zw := zip.NewWriter(&zb) + for name, body := range map[string]string{"world/level.dat": "level", "world/region/r.0.0.mca": "region!"} { + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + io.WriteString(w, body) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "maps", "a.zip"), zb.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + if code := cmdFiles([]string{"--op", "unzip", "--path", "maps/a.zip", "--worlds-root", root}, &stdout, &stderr); code != 0 { + t.Fatalf("exit %d, stderr %q", code, stderr.String()) + } + if res := filesResult(t, stdout.String()); res.Code != "" || res.Files != 2 || res.Bytes != 12 { + t.Fatalf("result = %+v", res) + } + if !strings.HasPrefix(stdout.String(), fileedit.ProgressPrefix) || + !strings.Contains(stdout.String(), fileedit.ProgressPrefix+`{"done":12,"total":12}`+"\n") { + t.Fatalf("stdout %q does not report the progress to the last byte", stdout.String()) + } + got, err := os.ReadFile(filepath.Join(root, "maps", "world", "region", "r.0.0.mca")) + if err != nil || string(got) != "region!" { + t.Fatalf("extracted %q, %v", got, err) + } +} diff --git a/cmd/felis/run.go b/cmd/felis/run.go index c3d22c2..7052dd8 100644 --- a/cmd/felis/run.go +++ b/cmd/felis/run.go @@ -21,7 +21,7 @@ Commands: restore Extract a world archive into a world volume (internal Job entrypoint) backup Archive a world into the backup store and record it (internal Job entrypoint) backup-now Archive every user server's world now, one at a time (or the named ones; -stop stops running ones first; prints the plan, -yes applies; requires root/sudo) - files List, read, write, mkdir, delete, rename or upload one path in a stopped server's world (internal Job entrypoint) + files List, read, write, mkdir, delete, rename, upload or unzip one path in a stopped server's world (internal Job entrypoint) export Archive a stopped server's world, or read one of its backups, and hand it to felis-api for download (internal Job entrypoint) egress-gate Hold a build or game server pod until its egress NetworkPolicy is enforced (internal init container entrypoint) fetch-context Fetch and extract a submission's build context (internal Job entrypoint) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 647b21f..b6fd3e7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -743,6 +743,60 @@ components: state: { type: string, const: pending } filename: { type: string, description: What the download saves as. } + FileUploadSession: + type: object + description: Where an upload session stands (internal/api/handlers_fileops.go fileSessionView). + required: [id, path, size, received, part_max_bytes] + properties: + id: { type: string, description: 32 hex characters. } + path: { type: string, description: Where the file lands, relative to the world root. } + size: { type: integer, format: int64, description: The file's length. } + received: { type: integer, format: int64, description: Bytes here so far; the next part starts here. } + part_max_bytes: { type: integer, format: int64, description: The most one part may carry. } + + StartFileOp: + type: object + properties: + overwrite: { type: boolean, description: Replace files already there. } + + FileOp: + type: object + description: One background upload or extraction (internal/api/handlers_fileops.go fileOpView). + required: [id, op, path, state, started_at, done, total] + properties: + id: { type: string } + op: { type: string, enum: [upload, unzip] } + path: { type: string, description: The file landed, or the archive extracted. } + state: { type: string, enum: [running, succeeded, failed] } + started_at: { type: string, format: date-time } + finished_at: { type: string, format: date-time, description: Omitted while it runs. } + done: { type: integer, format: int64, description: Bytes landed or extracted so far; 0 before the first report. } + total: { type: integer, format: int64, description: Bytes in all; 0 before the first report. } + files: { type: integer, description: Files an extraction wrote. Omitted otherwise. } + bytes: { type: integer, format: int64, description: Bytes an extraction wrote. Omitted otherwise. } + error: { $ref: '#/components/schemas/FileOpError' } + + FileOpError: + type: object + description: >- + Why an op failed (internal/api/handlers_fileops.go fileOpError). code is + what the synchronous file routes answer for the same refusal + (file_exists, volume_full, file_changed, not_found, bad_path), an + extraction's own (archive_invalid, archive_unsafe, archive_symlink, + type_conflict), or job_failed for a Job that ended without saying why. + required: [code, message] + properties: + code: { type: string } + message: { type: string } + entry: { type: string, description: The archive entry refused, or the path it collides with. } + conflicts: + type: array + items: { type: string } + description: On file_exists from an extraction, the first 200 files it would replace, sorted. + conflict_count: { type: integer, description: How many files it would replace in all. } + need: { type: integer, format: int64, description: On volume_full, the bytes needed. } + avail: { type: integer, format: int64, description: On volume_full, the bytes free. } + ExportStatus: type: object description: Where an export stands (internal/api/exports.go exportStatusView). @@ -1306,7 +1360,10 @@ paths: face and the bearer token minted with the upload is the whole check. The token opens its upload once. An unknown id, a wrong or missing token and a spent token are all the same 404, so the route says nothing about which - uploads exist. + uploads exist. An upload session committed through POST + …/files/uploads/{id}/commit is fetched here the same way, under the + session id; it stays staged until it has been sent whole once, so a Job + that failed before then can be committed again. x-felis-face: [internal] x-felis-tier: public security: [] @@ -4149,11 +4206,14 @@ paths: description: >- Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, - then open its download). The archive is checked against the sha256 - recorded when it was written as it streams; a mismatch aborts the - download. A user gets 404 for a backup outside their scope, as their - list never shows it. One export per user at a time, 2 across the - install, 6 per user per hour. + then open its download). The Job checks the archive against the sha256 + recorded when it was written as it streams; a mismatch cuts the + download off short of its end. On the way out config/paper-global.yml + (the cluster's forwarding secret) is left out and server.properties has + its rcon.password redacted, so the download carries no Content-Length. + A user gets 404 for a backup outside their scope, as their list never + shows it. One export per user at a time, 2 across the install, 6 per + user per hour. x-felis-face: [external] x-felis-tier: app security: [{ sessionCookie: [] }] @@ -4206,7 +4266,9 @@ paths: it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The server must be fully stopped, and it cannot start until the download has ended or the Job's - 2 hour deadline passes. Same limits as a backup export. + 2 hour deadline passes. The same two files are guarded as in a backup + export, matched by the file itself, so a link to either under another + name is guarded too. Same limits as a backup export. x-felis-face: [external] x-felis-tier: app security: [{ sessionCookie: [] }] @@ -4295,13 +4357,19 @@ paths: - { name: ticket, in: path, required: true, schema: { type: string } } responses: '200': - description: The tar.gz, as an attachment. + description: >- + The export as an attachment: a world or a backup as a tar.gz, a + downloaded folder as a zip, a downloaded file as its bytes. headers: Content-Disposition: schema: { type: string } content: application/gzip: schema: { type: string, format: binary } + application/zip: + schema: { type: string, format: binary } + application/octet-stream: + schema: { type: string, format: binary } '401': $ref: '#/components/responses/Unauthorized' '404': @@ -4355,7 +4423,7 @@ paths: required: [name, kind, state] properties: name: { type: string } - kind: { type: string, enum: [backup, restore, export_world, export_backup] } + kind: { type: string, enum: [backup, restore, export_world, export_backup, export_files] } state: { type: string, enum: [running, succeeded, failed] } message: { type: string } started_at: { type: string, format: date-time } @@ -4424,10 +4492,11 @@ paths: application/json: schema: type: object - required: [path, entries, truncated] + required: [path, entries, truncated, free_bytes] properties: path: { type: string } truncated: { type: boolean, description: The listing hit the entry cap and is incomplete. } + free_bytes: { type: integer, format: int64, description: Bytes free on the world volume, for a client to check an upload fits before sending it. } entries: type: array items: @@ -4818,6 +4887,75 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /api/v1/servers/{name}/files/download: + post: + tags: [files] + operationId: downloadServerFile + summary: Start downloading one file or folder of a stopped server's world (owner-or-admin). + description: >- + An export (poll GET /exports/{ticket}, then open its download): a Job + reads the file, or zips the folder, from the world volume read-only and + hands it to felis-api, which streams it to the browser. A file saves + under its own name with its length; a folder as NAME.zip, streamed + without one, with symbolic links, devices and sockets left out. + config/paper-global.yml, the cluster's forwarding secret, is refused as + a file and left out of a folder, and server.properties goes out with + its rcon.password redacted; both are matched by the file itself, so a + link to either under another name is guarded too. The server cannot + start until the download has ended. Two file downloads per user at a + time, 4 across the install, 30 per user per hour, counted apart from + world and backup exports. Audited as file.download. + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + - name: path + in: query + required: true + description: File or folder to download, relative to the world root. The root itself is refused. + schema: { type: string } + - name: dir + in: query + required: false + description: true when path is a folder, which is sent as a zip. The Job refuses a path that is not what dir says. + schema: { type: string, enum: ["true", "false"] } + responses: + '202': + description: Download started. + content: + application/json: + schema: { $ref: '#/components/schemas/ExportTicket' } + '400': + description: Missing path (bad_request), the world root (bad_path), or a malformed server name (bad_name). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Unknown server. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: Server is not stopped (not_stopped), has no world volume yet (no_world_volume), or a restore, backup, file change or another export already holds its world volume (maintenance_in_progress). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: A file download limit is reached (export_busy); Retry-After gives the seconds to wait. + headers: + Retry-After: + schema: { type: integer } + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + /api/v1/servers/{name}/files/upload: put: tags: [files] @@ -4825,7 +4963,8 @@ paths: summary: Upload a file into a server's world volume (owner-or-admin; server must be stopped). description: >- Lands the raw request body as the file at path, up to 64 MiB — a plugin jar, - a datapack, a world region. Content-Length is required (411 + a datapack, a world region; a bigger file goes up as an upload session + (POST …/files/uploads). Content-Length is required (411 length_required). An existing file is 409 file_exists unless overwrite=true; a folder at the path is 400 bad_path either way. The body is staged on felis-api's disk first and then fetched by the file Job with a one-time @@ -4895,7 +5034,7 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } '413': - description: The file is over 64 MiB (too_large). + description: The file is over 64 MiB, the most one request carries (too_large); send it as an upload session instead. content: application/json: schema: { $ref: '#/components/schemas/Error' } @@ -4915,6 +5054,390 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /api/v1/servers/{name}/files/uploads: + post: + tags: [files] + operationId: beginServerFileUpload + summary: Begin an upload session for a file too big for one request (owner-or-admin; server must be stopped). + description: >- + A file of any size goes up in parts: this begins a session for path and + the file's size, PUT …/uploads/{id}?offset= sends each part (at most + part_max_bytes, 32 MiB, so each fits the edge's body limit), and POST + …/uploads/{id}/commit lands it. There is no size ceiling but felis-api's + staging disk, and room for the whole file is reserved here, so an upload + that begins is one the disk can finish (507 upload_staging_full + otherwise). A session belongs to the account and server it was begun + for, answers no one else, and is dropped after 6 hours untouched. Four + sessions per account at a time. Sessions do not survive a felis-api + restart. + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + - name: path + in: query + required: true + description: File to create, relative to the world root. It must stay inside it (400 bad_path); its folder is checked when the file lands. + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [size] + properties: + size: { type: integer, format: int64, minimum: 0, description: The file's length in bytes. } + responses: + '201': + description: Session begun. + content: + application/json: + schema: { $ref: '#/components/schemas/FileUploadSession' } + '400': + description: Missing path or size, or a negative size (bad_request), a path leaving the world folder or naming the folder itself (bad_path), or a malformed server name (bad_name). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Unknown server. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: Server is not stopped (not_stopped) or has no world volume yet (no_world_volume). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '429': + description: The account already has 4 uploads in progress (too_many_uploads). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + '507': + description: felis-api's staging disk has no room for a file this size right now (upload_staging_full). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + + /api/v1/servers/{name}/files/uploads/{id}: + get: + tags: [files] + operationId: getServerFileUpload + summary: Where an upload session stands (owner-or-admin, the account that began it). + description: >- + received is where the next part starts: after a lost answer or a 409 + upload_offset_mismatch, read it here and continue from there. Needs no + stopped server. + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '200': + description: The session. + content: + application/json: + schema: { $ref: '#/components/schemas/FileUploadSession' } + '400': + description: Malformed server name (bad_name). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Unknown server, or no such session for this account on this server (upload_not_found). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + put: + tags: [files] + operationId: putServerFileUploadPart + summary: Send one part of an upload session (owner-or-admin, the account that began it). + description: >- + The raw body is appended at offset, which must be where the session + ends. Content-Length is required, and the part is taken whole or not at + all: one cut short leaves the session where it was. Parts go one at a + time (409 upload_busy while one arrives). Needs no stopped server, so + starting the server midway costs only the commit's refusal until it is + stopped again. + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + - { name: id, in: path, required: true, schema: { type: string } } + - name: offset + in: query + required: true + description: The byte position the part starts at, the session's received. + schema: { type: integer, format: int64, minimum: 0 } + requestBody: + required: true + content: + application/octet-stream: + schema: { type: string, format: binary } + responses: + '200': + description: Part taken. + content: + application/json: + schema: { $ref: '#/components/schemas/FileUploadSession' } + '400': + description: A missing or malformed offset (bad_request), a body that ended before its Content-Length (upload_incomplete), or a malformed server name (bad_name). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Unknown server, or no such session for this account on this server (upload_not_found). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: offset is not where the session ends (upload_offset_mismatch), or another part is still arriving (upload_busy). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '411': + description: The request has no Content-Length (length_required). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '413': + description: The part is over part_max_bytes, or runs past the size the session began with (part_too_large). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + '507': + description: felis-api's staging disk ran out of room (upload_staging_full). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + delete: + tags: [files] + operationId: deleteServerFileUpload + summary: Cancel an upload session and free its room (owner-or-admin, the account that began it). + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + - { name: id, in: path, required: true, schema: { type: string } } + responses: + '204': + description: Cancelled. + '400': + description: Malformed server name (bad_name). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Unknown server, or no such session for this account on this server (upload_not_found). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: A part is still arriving (upload_busy). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + + /api/v1/servers/{name}/files/uploads/{id}/commit: + post: + tags: [files] + operationId: commitServerFileUpload + summary: Land a finished upload session in the world volume (owner-or-admin; server must be stopped). + description: >- + Starts the Job that fetches the session's bytes from felis-api and lands + them at its path, checked against their size and SHA-256 and renamed into + place, so a failed landing leaves the old file whole. It answers at once + with the op; GET …/files/ops reports how it ends (file_exists when a file + is at the path and overwrite is not true). The Job holds the world volume + while it runs, so the server cannot start meanwhile. A Job that fails + before it has every byte leaves the session to commit again; once the + bytes have gone to the Job the session is gone. Audited as file.upload. + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + - { name: id, in: path, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/StartFileOp' } + responses: + '202': + description: Landing started. + content: + application/json: + schema: + type: object + required: [op] + properties: + op: { $ref: '#/components/schemas/FileOp' } + '400': + description: Malformed body, or a malformed server name (bad_name). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Unknown server, or no such session for this account on this server (upload_not_found). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: >- + Not every byte has arrived (upload_incomplete; the world lock is not + asked for), a part is still arriving (upload_busy), the server is not stopped (not_stopped) or has + no world volume yet (no_world_volume), or a restore, backup, file + change or export already holds its world volume, this session's + earlier commit included (maintenance_in_progress). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + + /api/v1/servers/{name}/files/unzip: + post: + tags: [files] + operationId: unzipServerFile + summary: Extract a .zip into the folder holding it (owner-or-admin; server must be stopped). + description: >- + Starts a Job that extracts the archive into a temporary folder beside it + and moves the result into place, and answers at once with the op; GET + …/files/ops reports how it ends. Nothing changes unless every entry is + safe: an entry leaving the folder, an absolute path, or a link ends + archive_unsafe or archive_symlink; an entry whose size differs from what + the archive declares ends archive_invalid; a file where the archive has + a folder, or the reverse, ends type_conflict. Without overwrite an + archive that would replace any file ends file_exists with the files it + would replace, for the caller to confirm and run again with overwrite. + Names stored in GBK, as Windows zips in a Chinese locale have them, are + read as such. The Job holds the world volume while it runs. Audited as + file.unzip. + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + - name: path + in: query + required: true + description: The .zip to extract, relative to the world root. + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/StartFileOp' } + responses: + '202': + description: Extraction started. + content: + application/json: + schema: + type: object + required: [op] + properties: + op: { $ref: '#/components/schemas/FileOp' } + '400': + description: Missing path or malformed body (bad_request), a path not ending in .zip (bad_path), or a malformed server name (bad_name). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Unknown server. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '409': + description: Server is not stopped (not_stopped), has no world volume yet (no_world_volume), or a restore, backup, file change or export already holds its world volume (maintenance_in_progress). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + + /api/v1/servers/{name}/files/ops: + get: + tags: [files] + operationId: listServerFileOps + summary: A server's background uploads and extractions (owner-or-admin). + description: >- + Newest first: the one running, if any, and those that ended within the + last 30 minutes, at most 10. Needs no stopped server. + x-felis-face: [external] + x-felis-tier: app + security: [{ sessionCookie: [] }] + parameters: + - { name: name, in: path, required: true, schema: { type: string } } + responses: + '200': + description: The ops. + content: + application/json: + schema: + type: object + required: [ops] + properties: + ops: + type: array + items: { $ref: '#/components/schemas/FileOp' } + '400': + description: Malformed server name (bad_name). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Unknown server. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '503': + $ref: '#/components/responses/ServiceUnavailable' + + # --------------------------------------------------- scheduled tasks (app) --- /api/v1/servers/{name}/schedules: get: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 569ee70..9d42f3f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3164,9 +3164,9 @@ for 10 seconds (the Free plan's limits). The panel's Files page is for the server's owner or an admin, and only while the server is fully stopped. Each call runs a one-shot `felis files` Job in the `minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and -`felis.lolicon.best/files-mode=`. +`felis.lolicon.best/files-mode=`. A listing or a read holds nothing. Every change (a save, a new file or folder, -a rename, a delete, an upload) holds the world for its Job (§3b), so a wake or a +a rename, a delete, an upload, an unzip) holds the world for its Job (§3b), so a wake or a second change in the meantime gets `409 maintenance_in_progress`. The panel sends uploads one at a time and greys its other changes until they finish. @@ -3178,13 +3178,19 @@ sends uploads one at a time and greys its other changes until they finish. | `409` | `file_changed` | The file changed after the editor read it | The editor offers to load the latest or overwrite it | | `400` | `bad_path` | The path leaves the world volume (`..`, an absolute path, a link pointing out), or it would move `server.properties`, `config` or `config/paper-global.yml`, or read `config/paper-global.yml` | Those three keep their names: the read path withholds their secrets by name, and `paper-global.yml` holds the proxy forwarding secret every server shares | | `404` | `not_found` | The path, or a new folder's parent, is gone | Refresh the listing | -| `413` | `too_large` | A read over 1 MiB, a save over 256 KiB, or an upload over 64 MiB | Upload a large file whole instead of editing it | +| `413` | `too_large` | A read over 1 MiB, a save over 256 KiB, or a one-request upload over 64 MiB | Upload a large file whole instead of editing it; the panel sends a file over 64 MiB in parts on its own | | `411` | `length_required` | An upload without `Content-Length` (a chunked body) | Upload from the panel, or with `curl -T`, which sends the length | | `400` | `upload_incomplete` | The body ended before its declared length | Retry; nothing was changed | | `507` | `upload_staging_full` | Staging this upload would leave felis-api's staging filesystem under 10% free | Free space on the uploads volume | | `507` | `volume_full` | The world volume ran out of space; the old file is left as it was | Delete files the server no longer needs, or grow its volume | | `504` | `files_timeout` | felis-api stopped waiting after 90 s | See below: the Job may still finish | | `503` | `files_unavailable` | felis-api runs without the file Job runner, or (for an upload) without a staging directory or its internal address | Check felis-api's startup log | +| `404` | `upload_not_found` | A large upload's session is gone: cancelled, already landed, idle for 6 hours, or felis-api restarted | Upload the file again | +| `409` | `upload_offset_mismatch` | A part did not start where the session ends (a lost answer, a second tab) | The panel reads where it stands and continues; nothing to do | +| `409` | `upload_busy` | Another part of the same upload is still arriving | Same | +| `413` | `part_too_large` | A part over 32 MiB, or past the size the upload began with | A client bug; upload from the panel | +| `409` | `upload_incomplete` | The commit came before every part had arrived | Same | +| `429` | `too_many_uploads` | The account already has 4 large uploads in progress | Finish or cancel one | **An upload travels in two legs.** The browser sends the body to felis-api, which stages it under `/var/lib/felis/uploads/.file-staging` on the uploads @@ -3208,9 +3214,58 @@ change waits on it with `maintenance_in_progress`; refresh the listing once it is gone to see whether the change landed. A Job is kept for two minutes after it ends, with its log. +**A file over 64 MiB goes up in parts.** The panel begins an upload session +(`POST …/files/uploads`), which reserves room for the whole file on the staging +filesystem at once, so an upload that starts can finish (`507 +upload_staging_full` otherwise). It then sends 32 MiB parts, each under the +Cloudflare edge's 100 MB body limit, retrying a part that fails and resuming +from where the session ends; there is no size cap beyond the room. Starting the +server midway costs only the commit, which needs it stopped again. The commit +starts the Job and answers at once (`202`); the Job fetches the file the same +way as above and runs up to 2 hours. A session belongs to the account and server +it was begun for, and one untouched for 6 hours is dropped (felis-api logs +`dropped N upload session(s) left idle`). A Job that fails before it has every +byte leaves the session, so committing again does not mean sending it again. +Folders cannot be uploaded: the panel asks for a `.zip` instead, because loose +files cut off midway would leave half a world. + +**Unzip** (`POST …/files/unzip`, `.zip` only) extracts into a hidden +`.felis-unzip-*` folder beside the archive and moves the result into place only +once every entry has been written and checked, so a failure changes nothing and +the working folder is removed. It checks, before writing a byte, that no entry +leaves the folder or is a link (`archive_unsafe`, `archive_symlink`), that the +archive does not put a file where the server has a folder or the reverse +(`type_conflict`), and that the volume has room (`volume_full`); an entry whose +size differs from what the archive declares ends `archive_invalid`. Names stored +in GBK, as Windows zips in a Chinese locale have them, are read as such. Without +replace, an archive that would overwrite files ends `file_exists` with the list, +which the panel shows for confirmation before running it again with replace. + +Large uploads and unzips run in the background: they keep going when the page is +closed, and `GET …/files/ops` lists the one running and those that ended in the +last 30 minutes, with bytes done and, on failure, the code above or `job_failed` +with the Job's condition (`DeadlineExceeded` after 2 hours). Their Jobs carry +`felis.lolicon.best/files-async=true`. A Job that fails without a result keeps +its log for 30 minutes: + +```sh +kubectl -n minecraft get jobs -l felis.lolicon.best/server=,felis.lolicon.best/files-async=true +``` + +**Downloading a file or folder** (`POST …/files/download`) is an export (§10, +"Downloading a backup or the world"): a `felis-export` Job with +`felis.lolicon.best/export-mode=files` reads the file, or zips the folder, from +the world read-only and felis-api streams it to the browser. It needs the server +stopped and holds the world until the download ends. `config/paper-global.yml` +is refused as a file and left out of a folder, and `server.properties` goes out +with `rcon.password` redacted, matched by the file itself so a link to either is +guarded too; world and backup exports filter the same two files. Two per user at +a time, four across the install, 30 per user per hour (`429 export_busy`). + Every change is audited as `file.write`, `file.mkdir`, `file.delete`, -`file.rename` (with `to`) or `file.upload` (with `size_bytes`, `sha256` and -`overwrite`), with `server_name` set to `:`: +`file.rename` (with `to`), `file.upload` (with `size_bytes`, `sha256` and +`overwrite`, in one request or in parts), `file.unzip` (with `overwrite`) or +`file.download`, with `server_name` set to `:`: ```sh sudo k3s kubectl -n felis exec deploy/felis-postgres -c postgres -- psql -U postgres felis -c " @@ -3218,8 +3273,8 @@ sudo k3s kubectl -n felis exec deploy/felis-postgres -c postgres -- psql -U post FROM audit_logs WHERE action LIKE 'file.%' ORDER BY created_at DESC LIMIT 20;" ``` -[GO-TESTED: `internal/fileedit`, `handlers_files_test.go`, `cmd/felis/files_test.go`, -`internal/maintenance`.] [VM-TESTED: a pod labelled as a files Job in `minecraft` +[GO-TESTED: `internal/fileedit`, `handlers_files_test.go`, `handlers_fileops_test.go`, +`cmd/felis/files_test.go`, `TestExpireFileSessions`, `internal/maintenance`.] [VM-TESTED: a pod labelled as a files Job in `minecraft` reaches `felis-api-internal:8081`; a 256 KiB save's content, split across six variables, lands byte for byte through the real binary, where one 140 KB variable fails with `argument list too long`. An upload through the API, both legs end to @@ -3331,5 +3386,5 @@ PG-TESTED: `TestScheduleStoreRunCAS`, `TestDueSchedules`, `TestSchedulesFollowTh | `FelisAuditWriteFailing` | §17 | | `felis breakGlass` sends no code / shows `Root override`; `otp_skipped` in the audit | §17 | | How long sessions, codes and audit rows are kept; export audit rows | §17 | -| Files page: a change or upload refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `volume_full`, `files_timeout`) | §18 | +| Files page: a change, upload or unzip refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `upload_not_found`, `volume_full`, `archive_unsafe`, `job_failed`, `files_timeout`) | §18 | | A scheduled task shows `skipped`, `missed` or `failed`; a task switched itself off after an owner change | §19 | diff --git a/go.mod b/go.mod index 3630971..ec81b9b 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/minio/minio-go/v7 v7.2.1 github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_model v0.6.1 + golang.org/x/text v0.39.0 k8s.io/api v0.31.3 k8s.io/apimachinery v0.31.3 k8s.io/client-go v0.31.0 @@ -99,7 +100,6 @@ require ( golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.3.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/internal/api/api.go b/internal/api/api.go index ee9c25a..a44efb9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -582,8 +582,8 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "POST", Pattern: "/api/v1/servers/{name}/world/export", h: a.handleExportWorld}, {Method: "GET", Pattern: "/api/v1/exports/{ticket}", h: a.handleExportStatus}, {Method: "GET", Pattern: "/api/v1/exports/{ticket}/download", h: a.handleExportDownload}, - // Server file manager: list, read, write, make a folder, delete, rename and - // upload in a STOPPED server's world volume (handlers_files.go). App-tier, + // Server file manager: list, read, write, make a folder, delete, rename, + // upload, unzip and download in a STOPPED server's world volume (handlers_files.go). App-tier, // exactly like the backup pair above and for the same reason — every route // gates on owner-or-admin inside the handler, so an owner repairs their own // broken server without an admin's Zero-Trust path. The path travels as ?path= @@ -605,6 +605,19 @@ func (a *API) externalAPIRoutes() []apiRoute { {Method: "POST", Pattern: "/api/v1/servers/{name}/files/mkdir", h: a.handleMkdir}, {Method: "POST", Pattern: "/api/v1/servers/{name}/files/rename", h: a.handleRenameFile}, {Method: "PUT", Pattern: "/api/v1/servers/{name}/files/upload", h: a.handleUploadFile}, + // A file too big for one request goes up in parts as an upload session, and + // lands, like an unzip, as a Job the request does not wait on; files/ops + // reports how those went (handlers_fileops.go). + {Method: "POST", Pattern: "/api/v1/servers/{name}/files/uploads", h: a.handleBeginFileUpload}, + {Method: "GET", Pattern: "/api/v1/servers/{name}/files/uploads/{id}", h: a.handleFileUploadStatus}, + {Method: "PUT", Pattern: "/api/v1/servers/{name}/files/uploads/{id}", h: a.handleFileUploadPart}, + {Method: "DELETE", Pattern: "/api/v1/servers/{name}/files/uploads/{id}", h: a.handleDropFileUpload}, + {Method: "POST", Pattern: "/api/v1/servers/{name}/files/uploads/{id}/commit", h: a.handleCommitFileUpload}, + {Method: "POST", Pattern: "/api/v1/servers/{name}/files/unzip", h: a.handleUnzipFile}, + {Method: "GET", Pattern: "/api/v1/servers/{name}/files/ops", h: a.handleListFileOps}, + // A file or folder download is an export (exports.go): it answers 202 + // with a ticket the export routes above serve. + {Method: "POST", Pattern: "/api/v1/servers/{name}/files/download", h: a.handleDownloadFile}, // Scheduled tasks (handlers_schedules.go): a console command, restart, stop, // start or backup at set times, which felis-api's runner fires. App-tier and // owner-or-admin inside the handler, like the console and power routes they diff --git a/internal/api/exports.go b/internal/api/exports.go index b2436ae..e3f38a6 100644 --- a/internal/api/exports.go +++ b/internal/api/exports.go @@ -8,30 +8,33 @@ import ( "encoding/hex" "errors" "fmt" - "hash" "io" "log" "mime" "net/http" + pathpkg "path" "strconv" "strings" "sync" "time" "felis.lolicon.best/internal/apis/felis/v1alpha1" + "felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/maintenance" "felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/worldexport" ) -// World export. The owner downloads a tar.gz of their world, either as it is -// now (the server stopped) or as one of its backups, straight into the browser: +// World export and file download. The owner downloads a tar.gz of their world, +// either as it is now (the server stopped) or as one of its backups, or one file +// or folder of a stopped world (a folder as a zip), straight into the browser: // -// 1. POST /servers/{name}/world/export or /servers/{name}/backups/{id}/export -// checks the caller and the server, admits the export against the limits -// below and starts a one-shot felis-export Job (internal/worldexport) that -// reads the world or the archive read-only. It answers 202 with a ticket: -// 256 random bits, good for the caller who started it and nobody else. +// 1. POST /servers/{name}/world/export, /servers/{name}/backups/{id}/export or +// /servers/{name}/files/download checks the caller and the server, admits +// the export against the limits below and starts a one-shot felis-export +// Job (internal/worldexport) that reads the world or the archive read-only. +// It answers 202 with a ticket: 256 random bits, good for the caller who +// started it and nobody else. // 2. The Job PUTs the archive to the internal face (PUT // /api/v1/internal/exports/{id} with the one-time token it was started // with), and that request waits, body unread, for the browser. @@ -41,10 +44,10 @@ import ( // archive passes through felis-api's memory once and never touches a disk // it owns, and the Job moves at the browser's pace. // -// A backup is checked against the sha256 recorded when it was written as it -// streams, and the last read is held back until the digest is known: a mismatch -// aborts the response, so the browser reports a failed download and never keeps -// a complete-looking corrupt file, and the Job is told backup_corrupt. +// A backup is checked by the Job against the sha256 recorded when it was +// written (cmd/felis export): on a mismatch the Job aborts its upload short of +// the archive's end, the download aborts with it, and the browser reports a +// failed download and never keeps a complete-looking corrupt file. // // Tickets live in felis-api's memory. A restart forgets them, and a Job that // then PUTs finds nothing and fails, which the jobs list shows. @@ -57,15 +60,57 @@ type Exporter interface { } // Limits on exports. Each one keeps a Job, a connection and a 64 KiB copy -// buffer alive for as long as a download takes, and a world export also keeps -// its server from starting. +// buffer alive for as long as a download takes, and a world export or a file +// download also keeps its server from starting. const ( exportMaxActive = 2 // admitted and not yet over, install-wide exportMaxPerUser = 1 exportPerHour = 6 // started by one user in any hour exportCopyBuffer = 32 << 10 + + // File downloads count apart from the world and backup exports, with + // their own limits: one is a file or a folder, usually small and over in + // seconds, and an owner fetching a few configs one after another must + // neither wait on an export nor hold one off. + fileExportMaxActive = 4 + fileExportMaxPerUser = 2 + fileExportPerHour = 30 ) +// exportClass is one set of export limits and how a refusal names them. +type exportClass struct { + maxActive, perUser, perHour int + busyUser, busyActive, busyHour string // each formats its limit +} + +var ( + worldExports = exportClass{ + maxActive: exportMaxActive, perUser: exportMaxPerUser, perHour: exportPerHour, + busyUser: "you already have %d export in progress; download it or let it expire first", + busyActive: "%d exports are already in progress; retry in a few minutes", + busyHour: "you have started %d exports in the last hour; retry later", + } + fileExports = exportClass{ + maxActive: fileExportMaxActive, perUser: fileExportMaxPerUser, perHour: fileExportPerHour, + busyUser: "you already have %d file downloads in progress; let one finish first", + busyActive: "%d file downloads are already in progress; retry in a minute", + busyHour: "you have started %d file downloads in the last hour; retry later", + } +) + +func (e *exportEntry) class() exportClass { + if e.files { + return fileExports + } + return worldExports +} + +// exportStarts keys a user's recent starts within one class. +type exportStarts struct { + userID string + files bool +} + // Timings. Vars only so a test can shrink them. var ( // exportClaimTTL is how long the Job's upload waits for the browser. @@ -102,8 +147,6 @@ type exportStatusView struct { Message string `json:"message,omitempty"` } -var errExportDigest = errors.New("the archive does not match the sha256 recorded when it was written") - func errExportExpired() error { return newError(http.StatusGone, "export_expired", "this export has expired or was already downloaded; start a new one") } @@ -119,13 +162,16 @@ type exportEntry struct { userID string server string mode string - sha256 string // what a backup must hash to; empty for a world + files bool // a file download, counted in fileExports filename string - job string - state string - message string - at time.Time // when it entered its state - upload *exportUpload + // contentType is what the download is served as: a gzip archive, a zip, + // or a single file's raw bytes. + contentType string + job string + state string + message string + at time.Time // when it entered its state + upload *exportUpload } // exportUpload is the Job's PUT, parked until a browser claims it. @@ -144,7 +190,7 @@ type exportRegistry struct { mu sync.Mutex byTicket map[string]*exportEntry byID map[string]*exportEntry - starts map[string][]time.Time // per user, oldest first + starts map[exportStarts][]time.Time // oldest first } func (a *API) exportTickets() *exportRegistry { @@ -152,7 +198,7 @@ func (a *API) exportTickets() *exportRegistry { a.exports = &exportRegistry{ byTicket: map[string]*exportEntry{}, byID: map[string]*exportEntry{}, - starts: map[string][]time.Time{}, + starts: map[exportStarts][]time.Time{}, } }) return a.exports @@ -187,38 +233,37 @@ func randomHex(n int) string { return hex.EncodeToString(b) } -// admit reserves the export e describes (its user, server, mode, filename and -// digest) and returns its upload token, or refuses it with export_busy. +// admit reserves the export e describes (its user, server, mode, class, +// filename and content type) and returns its upload token, or refuses it with +// export_busy. Only exports of e's class count against it. func (g *exportRegistry) admit(e *exportEntry, now time.Time) (string, error) { g.mu.Lock() defer g.mu.Unlock() g.sweepLocked(now) active, mine := 0, 0 for _, o := range g.byTicket { - if o.active() { + if o.active() && o.files == e.files { active++ if o.userID == e.userID { mine++ } } } - switch starts := g.starts[e.userID]; { - case mine >= exportMaxPerUser: - return "", newError(http.StatusTooManyRequests, "export_busy", - "you already have an export in progress; download it or let it expire first").retryAfter(exportClaimTTL) - case active >= exportMaxActive: - return "", newError(http.StatusTooManyRequests, "export_busy", - "%d exports are already in progress; retry in a few minutes", exportMaxActive).retryAfter(time.Minute) - case len(starts) >= exportPerHour: - return "", newError(http.StatusTooManyRequests, "export_busy", - "you have started %d exports in the last hour; retry later", exportPerHour).retryAfter(starts[0].Add(time.Hour).Sub(now)) + c, key := e.class(), exportStarts{e.userID, e.files} + switch starts := g.starts[key]; { + case mine >= c.perUser: + return "", newError(http.StatusTooManyRequests, "export_busy", c.busyUser, c.perUser).retryAfter(exportClaimTTL) + case active >= c.maxActive: + return "", newError(http.StatusTooManyRequests, "export_busy", c.busyActive, c.maxActive).retryAfter(time.Minute) + case len(starts) >= c.perHour: + return "", newError(http.StatusTooManyRequests, "export_busy", c.busyHour, c.perHour).retryAfter(starts[0].Add(time.Hour).Sub(now)) } token := randomHex(32) e.ticket, e.id, e.tokenHash = randomHex(32), randomHex(8), sha256.Sum256([]byte(token)) e.state, e.at = exportPending, now g.byTicket[e.ticket] = e g.byID[e.id] = e - g.starts[e.userID] = append(g.starts[e.userID], now) + g.starts[key] = append(g.starts[key], now) return token, nil } @@ -228,10 +273,15 @@ func (g *exportRegistry) drop(e *exportEntry) { defer g.mu.Unlock() delete(g.byTicket, e.ticket) delete(g.byID, e.id) - ts := g.starts[e.userID] + key := exportStarts{e.userID, e.files} + ts := g.starts[key] for i := len(ts) - 1; i >= 0; i-- { if ts[i].Equal(e.at) { - g.starts[e.userID] = append(ts[:i:i], ts[i+1:]...) + if rest := append(ts[:i:i], ts[i+1:]...); len(rest) > 0 { + g.starts[key] = rest + } else { + delete(g.starts, key) + } break } } @@ -377,13 +427,13 @@ func (a *API) handleExportBackup(w http.ResponseWriter, r *http.Request) { return } e := &exportEntry{userID: p.UserID, server: name, mode: worldexport.ModeBackup, - filename: fmt.Sprintf("%s-backup-%s.tar.gz", name, backup.ID), sha256: backup.SHA256} + filename: fmt.Sprintf("%s-backup-%s.tar.gz", name, backup.ID), contentType: archiveContentType} token, err := a.exportTickets().admit(e, a.now()) if err != nil { writeError(w, r, err) return } - if !a.startExport(w, r, e, token, backup.BackupRef) { + if !a.startExport(w, r, e, token, worldexport.Request{BackupRef: backup.BackupRef, BackupSHA256: backup.SHA256}) { return } a.auditEntry(r, AuditEntry{Actor: auditActor(p), ActorUserID: p.UserID, Action: "backup.export", ServerName: rec.Name, @@ -425,7 +475,8 @@ func (a *API) handleExportWorld(w http.ResponseWriter, r *http.Request) { } reg := a.exportTickets() e := &exportEntry{userID: p.UserID, server: name, mode: worldexport.ModeWorld, - filename: fmt.Sprintf("%s-world-%s.tar.gz", name, a.now().UTC().Format("20060102-150405"))} + filename: fmt.Sprintf("%s-world-%s.tar.gz", name, a.now().UTC().Format("20060102-150405")), + contentType: archiveContentType} token, err := reg.admit(e, a.now()) if err != nil { writeError(w, r, err) @@ -437,15 +488,79 @@ func (a *API) handleExportWorld(w http.ResponseWriter, r *http.Request) { return } defer release() - if !a.startExport(w, r, e, token, "") { + if !a.startExport(w, r, e, token, worldexport.Request{}) { return } a.auditEntry(r, AuditEntry{Actor: auditActor(p), ActorUserID: p.UserID, Action: "world.export", ServerName: rec.Name}) writeJSON(w, http.StatusAccepted, exportTicketView{Ticket: e.ticket, State: exportPending, Filename: e.filename}) } +// archiveContentType is how a world or a backup export is served. +const archiveContentType = "application/gzip" + +// handleDownloadFile starts the download of one file or folder of a stopped +// server's world (POST /api/v1/servers/{name}/files/download?path=…&dir=true +// for a folder). The gate is the file manager's (authorizeFileOp), plus an +// account to bind the ticket to. The download is an export: a felis-export Job +// in files mode reads the file, or zips the folder, from the world volume and +// hands it over through a ticket like any other, under the world-volume lock +// (as KindExport) so the server cannot start mid-zip. +// +// The path is passed to the Job as it came, as every file route does (see +// handleListFiles): the Job's os.Root is the containment, and the guards run +// there. Only the world root is refused here, since a whole world is what the +// world export is for. +func (a *API) handleDownloadFile(w http.ResponseWriter, r *http.Request) { + name, ok := a.authorizeFileOp(w, r) + if !ok { + return + } + p := principalFromContext(r.Context()) + if p.UserID == "" { + writeError(w, r, errForbidden) + return + } + path, ok := requirePath(w, r) + if !ok { + return + } + dir := r.URL.Query().Get("dir") == "true" + base := pathpkg.Base(pathpkg.Clean("/" + path)) + if base == "/" { + writeError(w, r, newError(http.StatusBadRequest, "bad_path", + "the whole world is not a file download; export the world from the backups page instead")) + return + } + if a.Exporter == nil || a.InternalBaseURL == "" { + writeError(w, r, errExportUnavailable()) + return + } + e := &exportEntry{userID: p.UserID, server: name, mode: worldexport.ModeFiles, files: true, + filename: base, contentType: fileedit.DownloadFileType} + if dir { + e.filename, e.contentType = base+".zip", fileedit.DownloadZipType + } + reg := a.exportTickets() + token, err := reg.admit(e, a.now()) + if err != nil { + writeError(w, r, err) + return + } + release, ok := a.acquireWorld(w, r, name, maintenance.KindExport, "stop the server before editing its files") + if !ok { + reg.drop(e) + return + } + defer release() + if !a.startExport(w, r, e, token, worldexport.Request{Path: path, Dir: dir}) { + return + } + a.auditFile(r, "file.download", name, path, map[string]any{"dir": dir}) + writeJSON(w, http.StatusAccepted, exportTicketView{Ticket: e.ticket, State: exportPending, Filename: e.filename}) +} + func errExportUnavailable() error { - return newError(http.StatusServiceUnavailable, "export_unavailable", "world export is not configured") + return newError(http.StatusServiceUnavailable, "export_unavailable", "world export and downloads are not configured") } // exportGate is the front half both export routes share: a valid name, a known @@ -471,12 +586,12 @@ func (a *API) exportGate(w http.ResponseWriter, r *http.Request) (string, *Serve } // startExport creates the admitted export's Job, or forgets the export and -// writes the error. -func (a *API) startExport(w http.ResponseWriter, r *http.Request, e *exportEntry, token, backupRef string) bool { - job, err := a.Exporter.Start(r.Context(), worldexport.Request{ - Server: e.server, Mode: e.mode, BackupRef: backupRef, ID: e.id, Token: token, - TargetURL: a.InternalBaseURL + "/api/v1/internal/exports/" + e.id, - }) +// writes the error. what carries the mode's own fields (the backup and its +// digest, or the path); the rest comes from e. +func (a *API) startExport(w http.ResponseWriter, r *http.Request, e *exportEntry, token string, what worldexport.Request) bool { + what.Server, what.Mode, what.ID, what.Token = e.server, e.mode, e.id, token + what.TargetURL = a.InternalBaseURL + "/api/v1/internal/exports/" + e.id + job, err := a.Exporter.Start(r.Context(), what) if err != nil { a.exportTickets().drop(e) writeError(w, r, err) @@ -533,7 +648,7 @@ func (a *API) handleExportDownload(w http.ResponseWriter, r *http.Request) { defer reg.finish(e.ticket, a.now()) h := w.Header() - h.Set("Content-Type", "application/gzip") + h.Set("Content-Type", e.contentType) if cd := mime.FormatMediaType("attachment", map[string]string{"filename": e.filename}); cd != "" { h.Set("Content-Disposition", cd) } else { @@ -546,7 +661,7 @@ func (a *API) handleExportDownload(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusOK) - err = copyExport(w, e.upload.body, e.sha256) + err = copyExport(w, e.upload.body) e.upload.done <- err if err != nil { log.Printf("api: export %s of %s ended early: %v", e.id, e.server, err) @@ -557,53 +672,27 @@ func (a *API) handleExportDownload(w http.ResponseWriter, r *http.Request) { } // copyExport copies body into w through a fixed 32 KiB buffer, restarting w's -// write deadline on every write. With want set, the last read is held back -// until the whole body hashes to it. -func copyExport(w http.ResponseWriter, body io.Reader, want string) error { - out := &heldWriter{w: w, rc: http.NewResponseController(w)} - var sum hash.Hash - if want != "" { - sum = sha256.New() - body = io.TeeReader(body, sum) - } +// write deadline on every write. +func copyExport(w http.ResponseWriter, body io.Reader) error { + out := &stallWriter{w: w, rc: http.NewResponseController(w)} if _, err := io.CopyBuffer(out, body, make([]byte, exportCopyBuffer)); err != nil { return err } - if sum != nil && !strings.EqualFold(hex.EncodeToString(sum.Sum(nil)), want) { - return errExportDigest - } - if err := out.flush(); err != nil { - return err - } _ = out.rc.SetWriteDeadline(time.Time{}) // the connection may serve another request return nil } -// heldWriter passes each write on one behind, keeping the latest back until -// flush, so the end of an archive reaches the browser only once it is checked. -// It has no ReadFrom, so io.CopyBuffer uses the buffer it is given. -type heldWriter struct { - w io.Writer - rc *http.ResponseController - held []byte +// stallWriter restarts the connection's write deadline before every write, so +// a write fails only once the browser has taken nothing for exportStall. It +// has no ReadFrom, so io.CopyBuffer uses the buffer it is given. +type stallWriter struct { + w io.Writer + rc *http.ResponseController } -func (h *heldWriter) Write(p []byte) (int, error) { - if err := h.flush(); err != nil { - return 0, err - } - h.held = append(h.held[:0], p...) - return len(p), nil -} - -func (h *heldWriter) flush() error { - if len(h.held) == 0 { - return nil - } - _ = h.rc.SetWriteDeadline(time.Now().Add(exportStall)) - _, err := h.w.Write(h.held) - h.held = h.held[:0] - return err +func (s *stallWriter) Write(p []byte) (int, error) { + _ = s.rc.SetWriteDeadline(time.Now().Add(exportStall)) + return s.w.Write(p) } // handleInternalExportUpload takes an export Job's archive (PUT @@ -611,9 +700,8 @@ func (h *heldWriter) flush() error { // export is the check, as for file uploads: an unknown id, a wrong token and a // token already used all read the same 404. The request then waits for the // browser, up to exportClaimTTL, and answers once the download has ended: 204 -// when it got the whole archive, 409 backup_corrupt when the archive did not -// match its recorded digest, 410 export_expired when nobody came for it or the -// browser left early. +// when it got the whole archive, 410 export_expired when nobody came for it, +// the browser left early or the Job itself cut the upload short. func (a *API) handleInternalExportUpload(w http.ResponseWriter, r *http.Request) { token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok { @@ -644,14 +732,11 @@ func (a *API) handleInternalExportUpload(w http.ResponseWriter, r *http.Request) return } } - switch err := <-up.done; { - case err == nil: - w.WriteHeader(http.StatusNoContent) - case errors.Is(err, errExportDigest): - writeError(w, r, newError(http.StatusConflict, "backup_corrupt", "%v", err)) - default: + if err := <-up.done; err != nil { writeError(w, r, newError(http.StatusGone, "export_expired", "the download ended before the archive did: %v", err)) + return } + w.WriteHeader(http.StatusNoContent) } // stallBody restarts the connection's read deadline on every read, so reading diff --git a/internal/api/exports_test.go b/internal/api/exports_test.go index 346872d..b0d0a35 100644 --- a/internal/api/exports_test.go +++ b/internal/api/exports_test.go @@ -4,13 +4,12 @@ import ( "bytes" "context" "crypto/rand" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "io" "net/http" "net/http/httptest" + "net/url" "regexp" "strconv" "strings" @@ -19,6 +18,7 @@ import ( "time" "felis.lolicon.best/internal/apis/felis/v1alpha1" + "felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/maintenance" "felis.lolicon.best/internal/worldexport" batchv1 "k8s.io/api/batch/v1" @@ -189,6 +189,7 @@ func randomBytes(n int) []byte { func TestExportBackupGate(t *testing.T) { t.Run("former owner starts a backup export", func(t *testing.T) { a, repo, cl, ex := exportFixture() + repo.backups[0].sha256 = strings.Repeat("cd", 32) v := beginExport(t, a.ExternalHandler(), backupPath, "owner1") if !hex64.MatchString(v.Ticket) || v.State != "pending" || v.Filename != "survival-backup-bk1.tar.gz" { t.Fatalf("ticket = %+v", v) @@ -198,6 +199,7 @@ func TestExportBackupGate(t *testing.T) { } r := ex.reqs[0] if r.Server != "survival" || r.Mode != worldexport.ModeBackup || r.BackupRef != "/backups/survival-bk1.tar.gz" || + r.BackupSHA256 != strings.Repeat("cd", 32) || r.Path != "" || r.Dir || !hex16.MatchString(r.ID) || !hex64.MatchString(r.Token) || r.Token == v.Ticket || r.TargetURL != exportBase+"/api/v1/internal/exports/"+r.ID { t.Fatalf("export request = %+v", r) @@ -271,7 +273,8 @@ func TestExportWorldGate(t *testing.T) { if v.Filename != "survival-world-"+exportStamp+".tar.gz" || v.State != "pending" { t.Fatalf("ticket = %+v", v) } - if len(ex.reqs) != 1 || ex.reqs[0].Mode != worldexport.ModeWorld || ex.reqs[0].BackupRef != "" || ex.reqs[0].Server != "survival" { + if len(ex.reqs) != 1 || ex.reqs[0].Mode != worldexport.ModeWorld || ex.reqs[0].BackupRef != "" || ex.reqs[0].BackupSHA256 != "" || + ex.reqs[0].Path != "" || ex.reqs[0].Server != "survival" { t.Fatalf("export requests = %+v", ex.reqs) } if strings.Join(cl.acquired, ",") != "survival:"+maintenance.KindExport || strings.Join(cl.released, ",") != "survival" { @@ -363,7 +366,7 @@ func TestExportWorldGate(t *testing.T) { } ex.err = nil beginExport(t, h, worldPath, "owner1") - if n := len(a.exportTickets().starts["owner1"]); n != 1 { + if n := len(a.exportTickets().starts[exportStarts{userID: "owner1"}]); n != 1 { t.Fatalf("hourly starts = %d, want only the export that got a Job", n) } }) @@ -681,82 +684,299 @@ func awaitResponse(t *testing.T, ch <-chan *http.Response) *http.Response { } } -// TestExportBackupDigest: a backup streams with its length and is checked -// against the sha256 recorded when it was written. A match downloads whole; a -// mismatch withholds the tail and aborts, so the browser never holds a -// complete-looking corrupt file, and the Job hears backup_corrupt. -func TestExportBackupDigest(t *testing.T) { +// TestExportStreamsWhatTheJobSends: the archive reaches the browser byte for +// byte, with the length the Job declared when it declared one. The Job checks a +// backup against its recorded digest itself and, on a mismatch, cuts its upload +// short of the end; the download then aborts too, so the browser never holds a +// complete-looking file. +func TestExportStreamsWhatTheJobSends(t *testing.T) { archive := randomBytes(3*exportCopyBuffer + 4321) - sum := sha256.Sum256(archive) + + t.Run("whole, with its length", func(t *testing.T) { + a, _, _, ex := exportFixture() + ext, in := exportServers(t, a) + v := beginExport(t, a.ExternalHandler(), backupPath, "owner1") + up := realUpload(t, in, ex.reqs[0], bytes.NewReader(archive), int64(len(archive))) + waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1") + resp, got, err := realDownload(ext, v.Ticket) + if resp == nil { + t.Fatalf("download: %v", err) + } + if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Length") != strconv.Itoa(len(archive)) || + err != nil || !bytes.Equal(got, archive) { + t.Fatalf("download = %d, Content-Length %q, read %d of %d bytes, err %v", + resp.StatusCode, resp.Header.Get("Content-Length"), len(got), len(archive), err) + } + if upResp := awaitResponse(t, up); upResp.StatusCode != http.StatusNoContent { + t.Fatalf("upload answered %d, want 204", upResp.StatusCode) + } + }) + + t.Run("an upload the Job cuts short aborts the download", func(t *testing.T) { + a, _, _, ex := exportFixture() + ext, in := exportServers(t, a) + v := beginExport(t, a.ExternalHandler(), backupPath, "owner1") + // As the Job's digest check fails: all but the end, then a read error, + // which aborts the chunked PUT. + pr, pw := io.Pipe() + go func() { + _, _ = pw.Write(archive[:len(archive)-100]) + pw.CloseWithError(errors.New("the backup archive does not match the sha256 recorded when it was written")) + }() + up := realUpload(t, in, ex.reqs[0], pr, -1) + waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1") + resp, got, err := realDownload(ext, v.Ticket) + if resp == nil { + t.Fatalf("download: %v", err) + } + if err == nil || len(got) > len(archive)-100 || !bytes.Equal(got, archive[:len(got)]) { + t.Fatalf("read %d of %d bytes, err %v; want an error short of the end", len(got), len(archive), err) + } + if upResp := awaitResponse(t, up); upResp.StatusCode != -1 { + t.Fatalf("the aborted upload answered %d", upResp.StatusCode) + } + }) + + t.Run("a browser that leaves early is what the Job hears", func(t *testing.T) { + a, _, _, ex := exportFixture() + ext, _ := exportServers(t, a) + v := beginExport(t, a.ExternalHandler(), worldPath, "owner1") + up := uploadExport(a.InternalHandler(), ex.reqs[0].ID, ex.reqs[0].Token, io.LimitReader(zeros{}, 1<<30)) + waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1") + req, _ := http.NewRequest("GET", ext.URL+"/api/v1/exports/"+v.Ticket+"/download", nil) + req.Header.Set("X-Test-User", "owner1") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if _, err := io.ReadFull(resp.Body, make([]byte, 1000)); err != nil { + t.Fatal(err) + } + resp.Body.Close() + if w := awaitUpload(t, up); w.Code != http.StatusGone || decodeErr(t, w) != "export_expired" { + t.Fatalf("upload answered %d %s, want 410 export_expired", w.Code, w.Body.String()) + } + }) +} + +const fileDownloadPath = "/api/v1/servers/survival/files/download?path=" + +// fileDownloadFixture is exportFixture with the file manager wired, which the +// file routes' gate requires. +func fileDownloadFixture() (*API, *fakeRepo, *fakeCluster, *fakeExporter) { + a, repo, cl, ex := exportFixture() + a.Files = &fakeFileEditor{} + return a, repo, cl, ex +} + +func TestFileDownloadGate(t *testing.T) { for _, tc := range []struct { - name string - digest string + name, query, filename, contentType, payload string + want worldexport.Request }{ - {"recorded digest matches", hex.EncodeToString(sum[:])}, - {"no digest recorded", ""}, - {"digest mismatch", strings.Repeat("ab", 32)}, + {"a file", "plugins/Essentials/config.yml", "config.yml", fileedit.DownloadFileType, `{"dir":false}`, + worldexport.Request{Server: "survival", Mode: worldexport.ModeFiles, Path: "plugins/Essentials/config.yml"}}, + {"a folder, as a zip named after it", "plugins/Essentials/&dir=true", "Essentials.zip", fileedit.DownloadZipType, `{"dir":true}`, + worldexport.Request{Server: "survival", Mode: worldexport.ModeFiles, Path: "plugins/Essentials/", Dir: true}}, + {"dir other than true is a file", "a.yml&dir=false", "a.yml", fileedit.DownloadFileType, `{"dir":false}`, + worldexport.Request{Server: "survival", Mode: worldexport.ModeFiles, Path: "a.yml"}}, } { t.Run(tc.name, func(t *testing.T) { - a, repo, _, ex := exportFixture() - repo.backups[0].sha256 = tc.digest - ext, in := exportServers(t, a) - v := beginExport(t, a.ExternalHandler(), backupPath, "owner1") - up := realUpload(t, in, ex.reqs[0], bytes.NewReader(archive), int64(len(archive))) - waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1") - resp, got, err := realDownload(ext, v.Ticket) - if resp == nil { - t.Fatalf("download: %v", err) + a, repo, cl, ex := fileDownloadFixture() + v := beginExport(t, a.ExternalHandler(), fileDownloadPath+tc.query, "owner1") + if !hex64.MatchString(v.Ticket) || v.State != "pending" || v.Filename != tc.filename { + t.Fatalf("ticket = %+v", v) } - if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Length") != strconv.Itoa(len(archive)) { - t.Fatalf("download = %d, Content-Length %q", resp.StatusCode, resp.Header.Get("Content-Length")) + if len(ex.reqs) != 1 { + t.Fatalf("exporter started %d Jobs, want 1", len(ex.reqs)) } - upResp := awaitResponse(t, up) - if tc.digest == strings.Repeat("ab", 32) { - if err == nil || len(got) >= len(archive) || !bytes.Equal(got, archive[:len(got)]) { - t.Fatalf("mismatch: read %d of %d bytes, err %v; want an error short of the end", len(got), len(archive), err) - } - if upResp.StatusCode != http.StatusConflict || errCode(mustRead(t, upResp.Body)) != "backup_corrupt" { - t.Fatalf("upload answered %d, want 409 backup_corrupt", upResp.StatusCode) - } - return + r := ex.reqs[0] + if !hex16.MatchString(r.ID) || !hex64.MatchString(r.Token) || r.TargetURL != exportBase+"/api/v1/internal/exports/"+r.ID { + t.Fatalf("export request = %+v", r) } - if err != nil || !bytes.Equal(got, archive) { - t.Fatalf("read %d of %d bytes, err %v", len(got), len(archive), err) + r.ID, r.Token, r.TargetURL = "", "", "" + if r != tc.want { + t.Fatalf("export request = %+v, want %+v", r, tc.want) } - if upResp.StatusCode != http.StatusNoContent { - t.Fatalf("upload answered %d, want 204", upResp.StatusCode) + if e := a.exportTickets().byTicket[v.Ticket]; e.contentType != tc.contentType || !e.files { + t.Fatalf("export served as %q, file download %v", e.contentType, e.files) + } + if strings.Join(cl.acquired, ",") != "survival:"+maintenance.KindExport || strings.Join(cl.released, ",") != "survival" { + t.Fatalf("lock acquired %v, released %v", cl.acquired, cl.released) + } + if len(repo.audits) != 1 || repo.audits[0].Action != "file.download" || repo.audits[0].ActorUserID != "owner1" || + repo.audits[0].ServerName != "survival:"+tc.want.Path || string(repo.audits[0].Payload) != tc.payload { + t.Fatalf("audit = %+v", repo.audits) } }) } - // The tail is withheld from the response writer itself, not only from - // whatever the connection had yet to send: with every write captured, a - // mismatch still ends short of the archive. - t.Run("the tail waits for the digest", func(t *testing.T) { - a, repo, _, ex := exportFixture() - repo.backups[0].sha256 = strings.Repeat("ab", 32) - ext := a.ExternalHandler() - v := beginExport(t, ext, backupPath, "owner1") - up := uploadExport(a.InternalHandler(), ex.reqs[0].ID, ex.reqs[0].Token, bytes.NewReader(archive)) - waitExportReady(t, ext, v.Ticket, "owner1") - w := httptest.NewRecorder() - func() { - defer func() { - if p := recover(); p != http.ErrAbortHandler { - t.Errorf("the download ended with %v, want the abort", p) - } - }() - r := httptest.NewRequest("GET", "/api/v1/exports/"+v.Ticket+"/download", nil) - r.Header.Set("X-Test-User", "owner1") - ext.ServeHTTP(w, r) - }() - if got := w.Body.Bytes(); len(got) != 3*exportCopyBuffer || !bytes.Equal(got, archive[:len(got)]) { - t.Fatalf("wrote %d of %d bytes, want all but the last read (%d)", len(got), len(archive), 3*exportCopyBuffer) - } - if w := awaitUpload(t, up); w.Code != http.StatusConflict || decodeErr(t, w) != "backup_corrupt" { - t.Fatalf("upload answered %d %s, want 409 backup_corrupt", w.Code, w.Body.String()) + t.Run("admin", func(t *testing.T) { + a, _, _, ex := fileDownloadFixture() + beginExport(t, a.ExternalHandler(), fileDownloadPath+"server.properties", "admin1") + if len(ex.reqs) != 1 { + t.Fatalf("exporter started %d Jobs, want 1", len(ex.reqs)) } }) + + busy := func(_ *API, c *fakeCluster) { + c.maintErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindFileWrite} + } + for _, tc := range []struct { + name, user, query string + edit func(*API, *fakeCluster) + code int + errCode string + }{ + {name: "stranger", user: "stranger", query: "a.yml", code: http.StatusForbidden, errCode: "forbidden"}, + {name: "principal without an account", user: "nouser", query: "a.yml", code: http.StatusForbidden, errCode: "forbidden"}, + {name: "running", user: "owner1", query: "a.yml", edit: func(_ *API, c *fakeCluster) { c.byName["survival"].Ready = true }, + code: http.StatusConflict, errCode: "not_stopped"}, + {name: "no world volume", user: "owner1", query: "a.yml", edit: func(_ *API, c *fakeCluster) { c.noWorld["survival"] = true }, + code: http.StatusConflict, errCode: "no_world_volume"}, + {name: "no file editor", user: "owner1", query: "a.yml", edit: func(a *API, _ *fakeCluster) { a.Files = nil }, + code: http.StatusServiceUnavailable, errCode: "files_unavailable"}, + {name: "no path", user: "owner1", query: "", code: http.StatusBadRequest, errCode: "bad_request"}, + {name: "the root", user: "owner1", query: ".&dir=true", code: http.StatusBadRequest, errCode: "bad_path"}, + {name: "the root, slashed", user: "owner1", query: "/&dir=true", code: http.StatusBadRequest, errCode: "bad_path"}, + {name: "the root, dotted", user: "owner1", query: "./", code: http.StatusBadRequest, errCode: "bad_path"}, + {name: "the root, walked back", user: "owner1", query: "plugins/..", code: http.StatusBadRequest, errCode: "bad_path"}, + {name: "no exporter", user: "owner1", query: "a.yml", edit: func(a *API, _ *fakeCluster) { a.Exporter = nil }, + code: http.StatusServiceUnavailable, errCode: "export_unavailable"}, + {name: "no internal URL", user: "owner1", query: "a.yml", edit: func(a *API, _ *fakeCluster) { a.InternalBaseURL = "" }, + code: http.StatusServiceUnavailable, errCode: "export_unavailable"}, + {name: "world busy", user: "owner1", query: "a.yml", edit: busy, code: http.StatusConflict, errCode: "maintenance_in_progress"}, + } { + t.Run(tc.name, func(t *testing.T) { + a, repo, cl, ex := fileDownloadFixture() + if tc.edit != nil { + tc.edit(a, cl) + } + w := do(a.ExternalHandler(), "POST", fileDownloadPath+tc.query, "", as(tc.user)) + if w.Code != tc.code { + t.Fatalf("code = %d, want %d (%s)", w.Code, tc.code, w.Body.String()) + } + if got := decodeErr(t, w); got != tc.errCode { + t.Errorf("error code = %q, want %q", got, tc.errCode) + } + // Nothing is left behind: no Job, no audit, no ticket, no start + // counted against the hour. + reg := a.exportTickets() + if len(ex.reqs) != 0 || len(repo.audits) != 0 || len(cl.released) != 0 || len(reg.byTicket) != 0 || len(reg.starts) != 0 { + t.Errorf("a refused download started %d Jobs, wrote %d audits, released %v, left %d tickets and %v", + len(ex.reqs), len(repo.audits), cl.released, len(reg.byTicket), reg.starts) + } + }) + } + + t.Run("a failed Job is refunded and releases the lock", func(t *testing.T) { + a, _, cl, ex := fileDownloadFixture() + ex.err = errors.New("jobs is forbidden") + if w := do(a.ExternalHandler(), "POST", fileDownloadPath+"a.yml", "", as("owner1")); w.Code != http.StatusInternalServerError { + t.Fatalf("failed Job: code = %d, want 500 (%s)", w.Code, w.Body.String()) + } + if reg := a.exportTickets(); len(reg.byTicket) != 0 || len(reg.starts) != 0 || strings.Join(cl.released, ",") != "survival" { + t.Fatalf("after a failed Job: %d tickets, starts %v, released %v", len(reg.byTicket), reg.starts, cl.released) + } + }) +} + +func TestFileDownloadLimits(t *testing.T) { + busy := func(t *testing.T, w *httptest.ResponseRecorder, message, retry string) { + t.Helper() + var raw map[string]map[string]string + _ = json.Unmarshal(w.Body.Bytes(), &raw) + if w.Code != http.StatusTooManyRequests || raw["error"]["code"] != "export_busy" || raw["error"]["message"] != message || + w.Header().Get("Retry-After") != retry { + t.Fatalf("refusal = %d %s Retry-After %q; want 429 %q Retry-After %s", w.Code, w.Body.String(), w.Header().Get("Retry-After"), message, retry) + } + } + + t.Run("two per user, counted apart from exports", func(t *testing.T) { + a, _, _, ex := fileDownloadFixture() + h := a.ExternalHandler() + beginExport(t, h, fileDownloadPath+"a.yml", "owner1") + beginExport(t, h, fileDownloadPath+"b.yml", "owner1") + beginExport(t, h, worldPath, "owner1") // file downloads do not hold an export off + busy(t, do(h, "POST", fileDownloadPath+"c.yml", "", as("owner1")), + "you already have 2 file downloads in progress; let one finish first", "90") + if len(ex.reqs) != 3 { + t.Fatalf("exporter started %d Jobs, want 3", len(ex.reqs)) + } + }) + + t.Run("four across the install", func(t *testing.T) { + a, _, _, ex := fileDownloadFixture() + h := a.ExternalHandler() + for _, u := range []string{"owner1", "owner1", "owner3", "owner3"} { + server := map[string]string{"owner1": "survival", "owner3": "gamma"}[u] + beginExport(t, h, "/api/v1/servers/"+server+"/files/download?path=a.yml", u) + } + busy(t, do(h, "POST", fileDownloadPath+"a.yml", "", as("admin1")), + "4 file downloads are already in progress; retry in a minute", "60") + if len(ex.reqs) != 4 { + t.Fatalf("exporter started %d Jobs, want 4", len(ex.reqs)) + } + }) + + t.Run("thirty per user per hour", func(t *testing.T) { + defer func(old time.Duration) { exportPendingTTL = old }(exportPendingTTL) + exportPendingTTL = time.Minute + a, _, _, _ := fileDownloadFixture() + var clock atomic.Int64 + a.Now = func() time.Time { return time.Unix(clock.Load(), 0) } + h := a.ExternalHandler() + for i := range fileExportPerHour { + clock.Store(1_700_000_000 + int64(i)*100) // each start outlives the last one's pending TTL + beginExport(t, h, fileDownloadPath+"a.yml", "owner1") + } + clock.Store(1_700_000_000 + 2950) + busy(t, do(h, "POST", fileDownloadPath+"a.yml", "", as("owner1")), + "you have started 30 file downloads in the last hour; retry later", "650") + beginExport(t, h, worldPath, "owner1") // exports keep their own hour + clock.Store(1_700_000_000 + 3600) + beginExport(t, h, fileDownloadPath+"a.yml", "owner1") + }) +} + +// TestFileDownloadServed: a file goes out as its raw bytes under its own name, +// with its length; a folder as a zip, streamed without one. +func TestFileDownloadServed(t *testing.T) { + for _, tc := range []struct { + name, query, contentType, disposition string + size int64 + }{ + {"a file", url.QueryEscape("plugins/配置 file.yml"), fileedit.DownloadFileType, + "attachment; filename*=utf-8''%E9%85%8D%E7%BD%AE%20file.yml", 64 << 10}, + {"a folder", "plugins&dir=true", fileedit.DownloadZipType, "attachment; filename=plugins.zip", -1}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _, _, ex := fileDownloadFixture() + ext, in := exportServers(t, a) + v := beginExport(t, a.ExternalHandler(), fileDownloadPath+tc.query, "owner1") + // Past what the server would buffer and measure itself when the + // handler sets no length. + body := randomBytes(64 << 10) + up := realUpload(t, in, ex.reqs[0], bytes.NewReader(body), tc.size) + waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1") + resp, got, err := realDownload(ext, v.Ticket) + if resp == nil || err != nil || !bytes.Equal(got, body) { + t.Fatalf("download: read %d bytes, %v", len(got), err) + } + wantLength := "" + if tc.size >= 0 { + wantLength = strconv.FormatInt(tc.size, 10) + } + h := resp.Header + if h.Get("Content-Type") != tc.contentType || h.Get("Content-Disposition") != tc.disposition || + h.Get("Content-Length") != wantLength || h.Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("download headers = %v", h) + } + if upResp := awaitResponse(t, up); upResp.StatusCode != http.StatusNoContent { + t.Fatalf("upload answered %d, want 204", upResp.StatusCode) + } + }) + } } // zeros reads as an endless run of zero bytes. @@ -873,7 +1093,7 @@ func TestK8sExportJobs(t *testing.T) { job := func(id, mode string) *batchv1.Job { j, err := worldexport.ExportJob(worldexport.JobParams{ Server: "survival", ID: id, Mode: mode, - WorldPVC: "world-survival-0", BackupPVC: "felis-backups", BackupRef: "/backups/a.tar.gz", + WorldPVC: "world-survival-0", BackupPVC: "felis-backups", BackupRef: "/backups/a.tar.gz", Path: "plugins", TargetURL: exportBase + "/x", Token: "t", Namespace: "minecraft", Image: "felis:1", }) if err != nil { @@ -882,9 +1102,10 @@ func TestK8sExportJobs(t *testing.T) { return j } world, backupJob := job("1111111111111111", worldexport.ModeWorld), job("2222222222222222", worldexport.ModeBackup) + files := job("3333333333333333", worldexport.ModeFiles) restoring := &batchv1.Job{ObjectMeta: metav1.ObjectMeta{Namespace: "minecraft", Name: "restore-survival-cc", Labels: map[string]string{jobServerLabel: "survival", jobManagedByLabel: jobManagedByRestore}}} - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(world, backupJob, restoring). + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(world, backupJob, files, restoring). WithStatusSubresource(&batchv1.Job{}).Build() k := NewK8sJobStatus(c, "minecraft") ctx := context.Background() @@ -900,7 +1121,8 @@ func TestK8sExportJobs(t *testing.T) { for _, j := range jobs { kinds[j.Name] = j.Kind + "/" + j.State } - want := map[string]string{world.Name: "export_world/running", backupJob.Name: "export_backup/running", restoring.Name: "restore/running"} + want := map[string]string{world.Name: "export_world/running", backupJob.Name: "export_backup/running", + files.Name: "export_files/running", restoring.Name: "restore/running"} if len(kinds) != len(want) { t.Fatalf("jobs = %v, want %v", kinds, want) } @@ -910,11 +1132,14 @@ func TestK8sExportJobs(t *testing.T) { } } - // The kinds agree with maintenance.JobKind: a world export holds the world, - // a backup export does not. + // The kinds agree with maintenance.JobKind: a world export and a file + // download hold the world, a backup export does not. if kind, holds := maintenance.JobKind(world); kind != maintenance.KindExport || !holds { t.Errorf("JobKind(world export) = %q, %v", kind, holds) } + if kind, holds := maintenance.JobKind(files); kind != maintenance.KindExport || !holds { + t.Errorf("JobKind(file download) = %q, %v", kind, holds) + } if _, holds := maintenance.JobKind(backupJob); holds { t.Error("a backup export holds the world") } diff --git a/internal/api/handlers_fileops.go b/internal/api/handlers_fileops.go new file mode 100644 index 0000000..38a0e46 --- /dev/null +++ b/internal/api/handlers_fileops.go @@ -0,0 +1,446 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "felis.lolicon.best/internal/fileedit" + "felis.lolicon.best/internal/maintenance" + "felis.lolicon.best/internal/naming" +) + +// A file too big for the one-request upload (handleUploadFile) arrives as an +// upload session instead: POST …/files/uploads begins one for a path and a +// size, PUT …/files/uploads/{id}?offset= sends it in parts of at most +// fileedit.PartBytes (each part fits the Cloudflare edge's body limit), and POST +// …/files/uploads/{id}/commit lands it. The session lives on felis-api's staging +// disk (fileedit.Stage, session.go), bound to the account and the server it was +// begun for; the room for the whole file is reserved when it begins, so there is +// no product ceiling on the size, only the disk. +// +// Landing a file that size, like unzipping an archive, can outlast a request, +// so both answer 202 with the op, and GET …/files/ops reports how far it has got +// and how it ended (fileedit.Editor.StartUpload, StartUnzip, Ops). The Job holds +// the world volume while it runs, as any file write does, and the server cannot +// start until it ends. + +// fileSessionView is where an upload session stands. +type fileSessionView struct { + ID string `json:"id"` + Path string `json:"path"` + Size int64 `json:"size"` + Received int64 `json:"received"` + PartMaxBytes int64 `json:"part_max_bytes"` +} + +func sessionView(s fileedit.Session) fileSessionView { + return fileSessionView{ID: s.ID, Path: s.Path, Size: s.Size, Received: s.Received, PartMaxBytes: fileedit.PartBytes} +} + +// beginFileUploadRequest is the POST …/files/uploads body. +type beginFileUploadRequest struct { + Size *int64 `json:"size"` +} + +// handleBeginFileUpload serves POST /api/v1/servers/{name}/files/uploads?path=… +// — begin an upload session for a file of body.size bytes that will land at +// path. The gate is the file manager's, so a server that is running is refused +// before any byte is sent; the parts that follow need only the account and the +// server, so starting the server midway costs the upload nothing but the +// commit's refusal until it is stopped again. +func (a *API) handleBeginFileUpload(w http.ResponseWriter, r *http.Request) { + name, ok := a.authorizeFileOp(w, r) + if !ok { + return + } + user, ok := a.requireFileStage(w, r) + if !ok { + return + } + path, ok := requirePath(w, r) + if !ok { + return + } + // The Job checks the path against the volume when the file lands, hours of + // upload later for a big one; one that could never land is refused now. + if !filepath.IsLocal(path) || filepath.Clean(path) == "." { + writeError(w, r, newError(http.StatusBadRequest, "bad_path", + "the path must name a file inside the world folder")) + return + } + var body beginFileUploadRequest + if err := decodeJSON(w, r, &body); err != nil { + writeError(w, r, err) + return + } + if body.Size == nil || *body.Size < 0 { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", + "size must be the file's length in bytes")) + return + } + s, err := a.FileStage.Begin(user, name, path, *body.Size) + if err != nil { + writeFileSessionError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, sessionView(s)) +} + +// handleFileUploadStatus serves GET /api/v1/servers/{name}/files/uploads/{id} +// — where the caller's session stands, so a client that lost a part's answer +// resumes from received. +func (a *API) handleFileUploadStatus(w http.ResponseWriter, r *http.Request) { + name, user, ok := a.authorizeFileSession(w, r) + if !ok { + return + } + s, err := a.FileStage.Status(user, name, r.PathValue("id")) + if err != nil { + writeFileSessionError(w, r, err) + return + } + writeJSON(w, http.StatusOK, sessionView(s)) +} + +// handleFileUploadPart serves PUT +// /api/v1/servers/{name}/files/uploads/{id}?offset=… — append the raw body to +// the caller's session. offset must be where the session ends (409 +// upload_offset_mismatch otherwise; the status says where), and Content-Length +// is required, as for the one-request upload: the part is taken whole or not at +// all, and a part that breaks midway leaves the session where it was. A part +// over fileedit.PartBytes is refused before a byte of it is read (Append). +func (a *API) handleFileUploadPart(w http.ResponseWriter, r *http.Request) { + name, user, ok := a.authorizeFileSession(w, r) + if !ok { + return + } + offset, err := strconv.ParseInt(r.URL.Query().Get("offset"), 10, 64) + if err != nil { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", + "offset must be the byte position the part starts at")) + return + } + if r.ContentLength < 0 { + writeError(w, r, newError(http.StatusLengthRequired, "length_required", + "a part needs a Content-Length")) + return + } + s, err := a.FileStage.Append(user, name, r.PathValue("id"), offset, r.Body, r.ContentLength) + if err != nil { + writeFileSessionError(w, r, err) + return + } + writeJSON(w, http.StatusOK, sessionView(s)) +} + +// handleDropFileUpload serves DELETE /api/v1/servers/{name}/files/uploads/{id} +// — cancel the caller's session and free the room it holds. +func (a *API) handleDropFileUpload(w http.ResponseWriter, r *http.Request) { + name, user, ok := a.authorizeFileSession(w, r) + if !ok { + return + } + if err := a.FileStage.Drop(user, name, r.PathValue("id")); err != nil { + writeFileSessionError(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// startFileOpRequest is the body of a commit or an unzip. +type startFileOpRequest struct { + Overwrite bool `json:"overwrite"` +} + +// handleCommitFileUpload serves POST +// /api/v1/servers/{name}/files/uploads/{id}/commit — land the caller's +// finished session at its path, replacing a file there only with +// body.overwrite (the op ends file_exists otherwise). It answers 202 with the +// op; GET …/files/ops reports how it ends. +// +// The world lock is taken BEFORE the session is sealed: a commit made while an +// earlier commit's Job is still fetching the file is refused by that Job's hold +// on the volume, so it never mints the fresh token that would lock the running +// Job out. The session outlives a Job that fails before fetching every byte, so +// such a commit is simply made again; one that fetched them all is gone. +func (a *API) handleCommitFileUpload(w http.ResponseWriter, r *http.Request) { + name, ok := a.authorizeFileOp(w, r) + if !ok { + return + } + user, ok := a.requireFileStage(w, r) + if !ok { + return + } + var body startFileOpRequest + if err := decodeJSON(w, r, &body); err != nil { + writeError(w, r, err) + return + } + id := r.PathValue("id") + s, err := a.FileStage.Status(user, name, id) + // Refused before the world lock is asked for; Seal checks again under the + // stage's own lock, for a part that arrives in between. + if err == nil && s.Received != s.Size { + err = fmt.Errorf("%w: %d of %d bytes are here", fileedit.ErrUploadIncomplete, s.Received, s.Size) + } + if err != nil { + writeFileSessionError(w, r, err) + return + } + release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files") + if !ok { + return + } + defer release() + staged, err := a.FileStage.Seal(user, name, id) + if err != nil { + writeFileSessionError(w, r, err) + return + } + op, err := a.Files.StartUpload(r.Context(), name, s.Path, fileedit.UploadSource{ + URL: a.InternalBaseURL + "/api/v1/internal/file-uploads/" + id, + Token: staged.Token, + Size: staged.Size, + SHA256: staged.SHA256, + }, body.Overwrite) + if err != nil { + writeFileEditError(w, r, err) + return + } + a.auditFile(r, "file.upload", name, s.Path, map[string]any{ + "size_bytes": staged.Size, "sha256": staged.SHA256, "overwrite": body.Overwrite, + }) + writeJSON(w, http.StatusAccepted, map[string]any{"op": opView(op)}) +} + +// handleUnzipFile serves POST /api/v1/servers/{name}/files/unzip?path=… — +// extract the .zip at path into the folder holding it. Without body.overwrite +// an archive that would replace any file changes nothing and the op ends +// file_exists with the list of them, for the caller to confirm and run again +// with overwrite. It answers 202 with the op, like a commit. +// +// Only the name is checked here, so a caller who picked the wrong file hears +// so at once; whether it is a zip, and whether every entry is safe to extract, +// is the Job's to decide (fileedit/unzip.go). +func (a *API) handleUnzipFile(w http.ResponseWriter, r *http.Request) { + name, ok := a.authorizeFileOp(w, r) + if !ok { + return + } + path, ok := requirePath(w, r) + if !ok { + return + } + if !strings.HasSuffix(strings.ToLower(path), ".zip") { + writeError(w, r, newError(http.StatusBadRequest, "bad_path", "only a .zip file can be extracted")) + return + } + var body startFileOpRequest + if err := decodeJSON(w, r, &body); err != nil { + writeError(w, r, err) + return + } + release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files") + if !ok { + return + } + defer release() + op, err := a.Files.StartUnzip(r.Context(), name, path, body.Overwrite) + if err != nil { + writeFileEditError(w, r, err) + return + } + a.auditFile(r, "file.unzip", name, path, map[string]any{"overwrite": body.Overwrite}) + writeJSON(w, http.StatusAccepted, map[string]any{"op": opView(op)}) +} + +// handleListFileOps serves GET /api/v1/servers/{name}/files/ops — the server's +// background uploads and unzips, newest first: the one running, if any, and +// those that ended within the last half hour. It has no stopped gate: while +// one runs the server cannot start, and a finished one is still worth showing +// after it has. +func (a *API) handleListFileOps(w http.ResponseWriter, r *http.Request) { + name, ok := a.authorizeServerFiles(w, r) + if !ok { + return + } + if a.Files == nil { + writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable", + "the file editor is not configured")) + return + } + ops, err := a.Files.Ops(r.Context(), name) + if err != nil { + writeError(w, r, err) + return + } + views := make([]fileOpView, 0, len(ops)) + for _, op := range ops { + views = append(views, opView(op)) + } + writeJSON(w, http.StatusOK, map[string]any{"ops": views}) +} + +// fileOpView is one background file operation as the API shows it. +type fileOpView struct { + ID string `json:"id"` + Op string `json:"op"` + Path string `json:"path"` + State string `json:"state"` + StartedAt time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + Done int64 `json:"done"` + Total int64 `json:"total"` + Files int `json:"files,omitempty"` + Bytes int64 `json:"bytes,omitempty"` + Error *fileOpError `json:"error,omitempty"` +} + +// fileOpError is why an op failed. Code is the one the synchronous file routes +// answer with for the same refusal (writeFileEditError), or an unzip's own +// (archive_invalid, archive_unsafe, archive_symlink, type_conflict), or +// job_failed for a Job that ended without saying why; the rest is what the Job +// reported about it. +type fileOpError struct { + Code string `json:"code"` + Message string `json:"message"` + Entry string `json:"entry,omitempty"` + Conflicts []string `json:"conflicts,omitempty"` + ConflictCount int `json:"conflict_count,omitempty"` + Need int64 `json:"need,omitempty"` + Avail int64 `json:"avail,omitempty"` +} + +func opView(op fileedit.OpState) fileOpView { + v := fileOpView{ + ID: op.ID, Op: op.Op, Path: op.Path, State: op.State, StartedAt: op.Started, + Done: op.Done, Total: op.Total, + } + if !op.Finished.IsZero() { + v.FinishedAt = &op.Finished + } + if op.Result != nil && op.Result.Code == "" { + v.Files, v.Bytes = op.Result.Files, op.Result.Bytes + } + if op.State == fileedit.OpFailed { + v.Error = opError(op) + } + return v +} + +// opError maps a failed op onto the API's codes. A Job that printed no result +// carries only its condition's reason (DeadlineExceeded, BackoffLimitExceeded): +// the log it left is the world's content and the runtime's, and none of it is +// the caller's to read. +func opError(op fileedit.OpState) *fileOpError { + res := op.Result + if res == nil { + msg := "the file operation stopped before it could report how it went (%s); run it again" + if op.Reason == "DeadlineExceeded" { + msg = "the file operation ran out of time (%s); run it again" + } + return &fileOpError{Code: "job_failed", Message: fmt.Sprintf(msg, op.Reason)} + } + code := res.Code + switch res.Code { + case fileedit.CodeExists: + code = "file_exists" + case fileedit.CodeNoSpace: + code = "volume_full" + case fileedit.CodeConflict: + code = "file_changed" + } + return &fileOpError{ + Code: code, Message: res.Error, Entry: res.Entry, + Conflicts: res.Conflicts, ConflictCount: res.ConflictCount, Need: res.Need, Avail: res.Avail, + } +} + +// requireFileStage checks the caller has an account to bind an upload session +// to and that sessions are configured, and returns the account. +func (a *API) requireFileStage(w http.ResponseWriter, r *http.Request) (string, bool) { + p := principalFromContext(r.Context()) + if p == nil || p.UserID == "" { + writeError(w, r, errForbidden) + return "", false + } + if a.FileStage == nil || a.InternalBaseURL == "" { + writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable", + "uploads are not configured")) + return "", false + } + return p.UserID, true +} + +// authorizeServerFiles is authorizeFileOp without the stopped and world-volume +// gates: the name is valid, the server exists, and the caller owns it or is +// staff. It returns the server name. +func (a *API) authorizeServerFiles(w http.ResponseWriter, r *http.Request) (string, bool) { + name := r.PathValue("name") + if err := naming.ValidateServerName(name); err != nil { + writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) + return "", false + } + rec, err := a.Repo.ServerByName(r.Context(), name) + if err != nil { + a.writeLookupError(w, r, err) + return "", false + } + if !a.isOwnerOrAdmin(principalFromContext(r.Context()), rec) { + writeError(w, r, errForbidden) + return "", false + } + return name, true +} + +// authorizeFileSession is the gate of a session's parts, status and cancel: +// the caller still owns the server (or is staff) and has the account the +// session was begun under. A session answers only that account on that server +// (fileedit.Stage), so it returns both. +func (a *API) authorizeFileSession(w http.ResponseWriter, r *http.Request) (name, user string, ok bool) { + name, ok = a.authorizeServerFiles(w, r) + if !ok { + return "", "", false + } + user, ok = a.requireFileStage(w, r) + return name, user, ok +} + +// writeFileSessionError maps the upload session's errors onto HTTP statuses. +func writeFileSessionError(w http.ResponseWriter, r *http.Request, err error) { + var offset *fileedit.OffsetError + switch { + case errors.Is(err, fileedit.ErrNotStaged): + writeError(w, r, newError(http.StatusNotFound, "upload_not_found", + "no such upload; it was cancelled, landed, or left idle too long, so start it again")) + case errors.Is(err, fileedit.ErrStageFull): + writeError(w, r, newError(http.StatusInsufficientStorage, "upload_staging_full", + "felis has no room to take this upload right now; try again later or ask an admin")) + case errors.Is(err, fileedit.ErrTooManySessions): + writeError(w, r, newError(http.StatusTooManyRequests, "too_many_uploads", + "you have %d uploads in progress; finish or cancel one first", fileedit.MaxSessionsPerUser)) + case errors.Is(err, fileedit.ErrUploadBusy): + writeError(w, r, newError(http.StatusConflict, "upload_busy", + "another request is still writing this upload; read where it stands and continue from there")) + case errors.As(err, &offset): + writeError(w, r, newError(http.StatusConflict, "upload_offset_mismatch", + "the upload holds %d bytes; send the part that starts there", offset.Received)) + case errors.Is(err, fileedit.ErrPartTooLarge): + writeError(w, r, newError(http.StatusRequestEntityTooLarge, "part_too_large", + "the part is larger than part_max_bytes, or runs past the size the upload began with")) + case errors.Is(err, fileedit.ErrShortUpload): + writeError(w, r, newError(http.StatusBadRequest, "upload_incomplete", + "the part ended before its Content-Length; read where the upload stands and send it again")) + case errors.Is(err, fileedit.ErrUploadIncomplete): + writeError(w, r, newError(http.StatusConflict, "upload_incomplete", + "the upload has not finished arriving; read where it stands and send the rest")) + default: + writeError(w, r, err) + } +} diff --git a/internal/api/handlers_fileops_test.go b/internal/api/handlers_fileops_test.go new file mode 100644 index 0000000..5027c6b --- /dev/null +++ b/internal/api/handlers_fileops_test.go @@ -0,0 +1,787 @@ +package api + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "felis.lolicon.best/internal/apis/felis/v1alpha1" + "felis.lolicon.best/internal/fileedit" + "felis.lolicon.best/internal/maintenance" +) + +const ( + sessionsRoute = "/api/v1/servers/survival/files/uploads" + unzipRoute = "/api/v1/servers/survival/files/unzip" + opsRoute = "/api/v1/servers/survival/files/ops" + sessionPath = "world/region/r.0.0.mca" +) + +var ( + fileOwner = &Principal{UserID: "owner1", Email: "owner1@example.net", Role: "user"} + fileStranger = &Principal{UserID: "stranger", Email: "stranger@example.net", Role: "user"} + fileAdmin = &Principal{UserID: "admin1", Email: "admin1@example.net", Role: "admin", ViaAdminAccess: true} +) + +// beginSession begins a session for size bytes at sessionPath and returns it. +func beginSession(t *testing.T, api *API, size int) fileSessionView { + t.Helper() + w := do(api.ExternalHandler(), "POST", sessionsRoute+"?path="+sessionPath, `{"size":`+strconv.Itoa(size)+`}`, jsonHeader) + if w.Code != http.StatusCreated { + t.Fatalf("begin: code = %d (%s)", w.Code, w.Body.String()) + } + return sessionAnswer(t, w) +} + +// sessionAnswer decodes a session answer, refusing a field the view lacks. +func sessionAnswer(t *testing.T, w *httptest.ResponseRecorder) fileSessionView { + t.Helper() + var s fileSessionView + dec := json.NewDecoder(strings.NewReader(w.Body.String())) + dec.DisallowUnknownFields() + if err := dec.Decode(&s); err != nil { + t.Fatalf("session answer: %v (%s)", err, w.Body.String()) + } + return s +} + +// doPart sends one part with the Content-Length given, whatever the body's own +// length: -1 sends none, and one past the body is a part cut short. +func doPart(h http.Handler, target, body string, length int64) *httptest.ResponseRecorder { + r := httptest.NewRequest("PUT", target, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/octet-stream") + r.ContentLength = length + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + recordContract(r, body, w) + return w +} + +func partAt(id string, offset int) string { + return sessionsRoute + "/" + id + "?offset=" + strconv.Itoa(offset) +} + +func errMessage(t *testing.T, w *httptest.ResponseRecorder) string { + t.Helper() + var raw map[string]map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("error body not JSON: %v (%s)", err, w.Body.String()) + } + return raw["error"]["message"] +} + +// fetchStaged is the Job's fetch of what a commit handed it. +func fetchStaged(api *API, src fileedit.UploadSource) *httptest.ResponseRecorder { + at := strings.TrimPrefix(src.URL, api.InternalBaseURL) + return do(api.InternalHandler(), "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token}) +} + +var opStarted = time.Date(2026, 9, 28, 10, 0, 0, 0, time.UTC) + +// TestFileUploadSession drives a session from begin to the Job's fetch across +// both faces, and each way it can go wrong on the way. +func TestFileUploadSession(t *testing.T) { + commit := func(api *API, id, body string) *httptest.ResponseRecorder { + return do(api.ExternalHandler(), "POST", sessionsRoute+"/"+id+"/commit", body, jsonHeader) + } + status := func(api *API, id string) *httptest.ResponseRecorder { + return do(api.ExternalHandler(), "GET", sessionsRoute+"/"+id, "", nil) + } + + t.Run("begin, parts, commit, and the Job fetches the whole file once", func(t *testing.T) { + api, repo, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + // Every other internal route wants a service token; the Job has none. + api.Internal = CallerTokens{CallerVelocity: "s3cr3t"} + files.op = fileedit.OpState{ID: "op1", Op: fileedit.OpUpload, Path: sessionPath, + State: fileedit.OpRunning, Started: opStarted} + h := api.ExternalHandler() + + s := beginSession(t, api, 10) + if len(s.ID) != 32 || s.Path != sessionPath || s.Size != 10 || s.Received != 0 || s.PartMaxBytes != fileedit.PartBytes { + t.Fatalf("begin = %+v", s) + } + + if w := doPart(h, partAt(s.ID, 0), "hello", 5); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 5 { + t.Fatalf("part 1: code = %d (%s)", w.Code, w.Body.String()) + } + // The same part again, as a client that lost the answer might send it. + w := doPart(h, partAt(s.ID, 0), "hello", 5) + if w.Code != http.StatusConflict || decodeErr(t, w) != "upload_offset_mismatch" || + errMessage(t, w) != "the upload holds 5 bytes; send the part that starts there" { + t.Fatalf("replayed part: code = %d (%s)", w.Code, w.Body.String()) + } + + // Too early: refused before the world lock is asked for. + w = commit(api, s.ID, `{}`) + if w.Code != http.StatusConflict || decodeErr(t, w) != "upload_incomplete" || + files.calls != 0 || len(cl.acquired) != 0 { + t.Fatalf("early commit: code = %d calls = %d acquired %v (%s)", w.Code, files.calls, cl.acquired, w.Body.String()) + } + + if w := status(api, s.ID); w.Code != http.StatusOK || sessionAnswer(t, w) != (fileSessionView{ + ID: s.ID, Path: sessionPath, Size: 10, Received: 5, PartMaxBytes: fileedit.PartBytes}) { + t.Fatalf("status: code = %d (%s)", w.Code, w.Body.String()) + } + if w := doPart(h, partAt(s.ID, 5), "world", 5); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 10 { + t.Fatalf("part 2: code = %d (%s)", w.Code, w.Body.String()) + } + if w := doPart(h, partAt(s.ID, 10), "!", 1); w.Code != http.StatusRequestEntityTooLarge || decodeErr(t, w) != "part_too_large" { + t.Fatalf("a part past the size: code = %d (%s)", w.Code, w.Body.String()) + } + + w = commit(api, s.ID, `{}`) + if w.Code != http.StatusAccepted { + t.Fatalf("commit: code = %d (%s)", w.Code, w.Body.String()) + } + want := map[string]any{"op": map[string]any{ + "id": "op1", "op": "upload", "path": sessionPath, "state": "running", + "started_at": "2026-09-28T10:00:00Z", "done": float64(0), "total": float64(0), + }} + if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) { + t.Fatalf("commit answer = %v, want %v", got, want) + } + digest := sha256.Sum256([]byte("helloworld")) + sum := hex.EncodeToString(digest[:]) + src := files.gotSource + if files.calls != 1 || files.gotOp != fileedit.OpUpload || files.gotServer != "survival" || files.gotPath != sessionPath || + src.URL != api.InternalBaseURL+"/api/v1/internal/file-uploads/"+s.ID || + src.Size != 10 || src.SHA256 != sum || len(src.Token) != 64 || files.gotOverwrite { + t.Fatalf("executor saw calls=%d op %q server %q path %q source %+v overwrite %v", + files.calls, files.gotOp, files.gotServer, files.gotPath, src, files.gotOverwrite) + } + if strings.Join(cl.acquired, ",") != "survival:"+maintenance.KindFileWrite || strings.Join(cl.released, ",") != "survival" { + t.Fatalf("lock acquired %v, released %v", cl.acquired, cl.released) + } + onlyAudit(t, repo, "file.upload", "survival:"+sessionPath, `{"overwrite":false,"sha256":"`+sum+`","size_bytes":10}`) + + fetched := fetchStaged(api, src) + if fetched.Code != http.StatusOK || fetched.Body.String() != "helloworld" || fetched.Header().Get("Content-Length") != "10" { + t.Fatalf("fetch: code = %d %q Content-Length %q", fetched.Code, fetched.Body.String(), fetched.Header().Get("Content-Length")) + } + if again := fetchStaged(api, src); again.Code != http.StatusNotFound { + t.Fatalf("second fetch: code = %d", again.Code) + } + // Served whole, so the session is gone and its file with it. + if w := status(api, s.ID); w.Code != http.StatusNotFound || decodeErr(t, w) != "upload_not_found" { + t.Fatalf("status after the fetch: code = %d (%s)", w.Code, w.Body.String()) + } + stageEmpty(t, api) + }) + + t.Run("a Job that never fetched is committed again with a fresh token", func(t *testing.T) { + api, repo, _, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + s := beginSession(t, api, 3) + doPart(api.ExternalHandler(), partAt(s.ID, 0), "abc", 3) + if w := commit(api, s.ID, `{}`); w.Code != http.StatusAccepted { + t.Fatalf("first commit: code = %d (%s)", w.Code, w.Body.String()) + } + first := files.gotSource + if w := commit(api, s.ID, `{"overwrite":true}`); w.Code != http.StatusAccepted || !files.gotOverwrite { + t.Fatalf("second commit: code = %d overwrite %v (%s)", w.Code, files.gotOverwrite, w.Body.String()) + } + second := files.gotSource + if w := fetchStaged(api, first); w.Code != http.StatusNotFound { + t.Fatalf("the first commit's token still opens it: code = %d", w.Code) + } + if w := fetchStaged(api, second); w.Code != http.StatusOK || w.Body.String() != "abc" { + t.Fatalf("fetch: code = %d %q", w.Code, w.Body.String()) + } + if len(repo.audits) != 2 || string(repo.audits[1].Payload) != `{"overwrite":true,"sha256":"`+second.SHA256+`","size_bytes":3}` { + t.Fatalf("audits = %+v", repo.audits) + } + }) + + // The running Job's hold on the world refuses the commit before Seal, so the + // token that Job carries still opens the file. + t.Run("a commit while the world is held leaves the running Job's token alone", func(t *testing.T) { + api, _, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + s := beginSession(t, api, 3) + doPart(api.ExternalHandler(), partAt(s.ID, 0), "abc", 3) + commit(api, s.ID, `{}`) + running := files.gotSource + cl.maintErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindFileWrite} + w := commit(api, s.ID, `{}`) + if w.Code != http.StatusConflict || decodeErr(t, w) != "maintenance_in_progress" || files.calls != 1 { + t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String()) + } + if w := fetchStaged(api, running); w.Code != http.StatusOK || w.Body.String() != "abc" { + t.Fatalf("the running Job lost its file: code = %d %q", w.Code, w.Body.String()) + } + }) + + t.Run("a Job that could not start is not audited and lets go of the world", func(t *testing.T) { + api, repo, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + s := beginSession(t, api, 3) + doPart(api.ExternalHandler(), partAt(s.ID, 0), "abc", 3) + files.err = errors.New("the cluster said no") + w := commit(api, s.ID, `{}`) + if w.Code != http.StatusInternalServerError || len(repo.audits) != 0 || strings.Join(cl.released, ",") != "survival" { + t.Fatalf("code = %d audits %+v released %v (%s)", w.Code, repo.audits, cl.released, w.Body.String()) + } + if w := status(api, s.ID); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 3 { + t.Fatalf("the session went with the failed start: code = %d (%s)", w.Code, w.Body.String()) + } + }) + + t.Run("a fetch cut short keeps the session for the next commit", func(t *testing.T) { + api, _, _, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + s := beginSession(t, api, 3) + doPart(api.ExternalHandler(), partAt(s.ID, 0), "abc", 3) + commit(api, s.ID, `{}`) + src := files.gotSource + r := httptest.NewRequest("GET", strings.TrimPrefix(src.URL, api.InternalBaseURL), nil) + r.Header.Set("Authorization", "Bearer "+src.Token) + cut := &brokenWriter{header: http.Header{}} + api.InternalHandler().ServeHTTP(cut, r) + if cut.code != http.StatusOK { + t.Fatalf("cut fetch: code = %d", cut.code) + } + if w := status(api, s.ID); w.Code != http.StatusOK { + t.Fatalf("status after a cut fetch: code = %d (%s)", w.Code, w.Body.String()) + } + commit(api, s.ID, `{}`) + if w := fetchStaged(api, files.gotSource); w.Code != http.StatusOK || w.Body.String() != "abc" { + t.Fatalf("refetch: code = %d %q", w.Code, w.Body.String()) + } + }) + + t.Run("a part cut short leaves the session where it was", func(t *testing.T) { + api, _, _, _ := mkFiles(t) + api.External = staticExternal{p: fileOwner} + s := beginSession(t, api, 10) + w := doPart(api.ExternalHandler(), partAt(s.ID, 0), "abc", 5) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "upload_incomplete" { + t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) + } + if w := status(api, s.ID); sessionAnswer(t, w).Received != 0 { + t.Fatalf("status = %s", w.Body.String()) + } + }) + + t.Run("while a part arrives, the session takes nothing else", func(t *testing.T) { + api, _, _, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + h := api.ExternalHandler() + s := beginSession(t, api, 6) + pr, pw := io.Pipe() + arriving := make(chan int) + go func() { + r := httptest.NewRequest("PUT", partAt(s.ID, 0), pr) + r.Header.Set("Content-Type", "application/octet-stream") + r.ContentLength = 3 + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + arriving <- w.Code + }() + // The write returns once the handler has read the byte, so the part is + // being appended. + if _, err := pw.Write([]byte("a")); err != nil { + t.Fatal(err) + } + for name, w := range map[string]*httptest.ResponseRecorder{ + "another part": doPart(h, partAt(s.ID, 0), "xyz", 3), + "cancel": do(h, "DELETE", sessionsRoute+"/"+s.ID, "", nil), + } { + if w.Code != http.StatusConflict || decodeErr(t, w) != "upload_busy" { + t.Errorf("%s: code = %d (%s)", name, w.Code, w.Body.String()) + } + } + pw.CloseWithError(errors.New("the client went away")) + if code := <-arriving; code != http.StatusBadRequest { + t.Fatalf("the broken part answered %d", code) + } + if w := status(api, s.ID); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 0 || files.calls != 0 { + t.Fatalf("status: code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String()) + } + }) + + t.Run("parts refused before a byte is read", func(t *testing.T) { + for _, c := range []struct { + name, target string + length int64 + code int + errCode string + }{ + {"no offset", sessionsRoute + "/%s", 3, http.StatusBadRequest, "bad_request"}, + {"an offset that is no number", sessionsRoute + "/%s?offset=abc", 3, http.StatusBadRequest, "bad_request"}, + {"no Content-Length", sessionsRoute + "/%s?offset=0", -1, http.StatusLengthRequired, "length_required"}, + {"a part over the cap", sessionsRoute + "/%s?offset=0", fileedit.PartBytes + 1, http.StatusRequestEntityTooLarge, "part_too_large"}, + // At the cap it is taken, and the three bytes behind it end short. + {"a part at the cap", sessionsRoute + "/%s?offset=0", fileedit.PartBytes, http.StatusBadRequest, "upload_incomplete"}, + } { + t.Run(c.name, func(t *testing.T) { + api, _, _, _ := mkFiles(t) + api.External = staticExternal{p: fileOwner} + s := beginSession(t, api, fileedit.PartBytes+10) + w := doPart(api.ExternalHandler(), fmt.Sprintf(c.target, s.ID), "abc", c.length) + if w.Code != c.code || decodeErr(t, w) != c.errCode { + t.Fatalf("code = %d (%s), want %d %s", w.Code, w.Body.String(), c.code, c.errCode) + } + if w := status(api, s.ID); sessionAnswer(t, w).Received != 0 { + t.Fatalf("status = %s", w.Body.String()) + } + }) + } + }) + + // The parts need only the account and the server: starting the server midway + // costs the upload nothing but the commit. + t.Run("parts, status and cancel go on while the server runs", func(t *testing.T) { + api, _, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + h := api.ExternalHandler() + s := beginSession(t, api, 3) + cl.byName["survival"].Ready = true + cl.byName["survival"].DesiredState = string(v1alpha1.DesiredRunning) + if w := doPart(h, partAt(s.ID, 0), "abc", 3); w.Code != http.StatusOK { + t.Fatalf("part: code = %d (%s)", w.Code, w.Body.String()) + } + if w := status(api, s.ID); w.Code != http.StatusOK { + t.Fatalf("status: code = %d (%s)", w.Code, w.Body.String()) + } + if w := commit(api, s.ID, `{}`); w.Code != http.StatusConflict || decodeErr(t, w) != "not_stopped" || files.calls != 0 { + t.Fatalf("commit: code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String()) + } + if w := do(h, "DELETE", sessionsRoute+"/"+s.ID, "", nil); w.Code != http.StatusNoContent { + t.Fatalf("cancel: code = %d (%s)", w.Code, w.Body.String()) + } + if w := status(api, s.ID); w.Code != http.StatusNotFound || decodeErr(t, w) != "upload_not_found" { + t.Fatalf("status after cancel: code = %d (%s)", w.Code, w.Body.String()) + } + stageEmpty(t, api) + }) + + t.Run("a session answers only the account and server it was begun for", func(t *testing.T) { + api, repo, _, files := mkFiles(t) + repo.byName["creative"] = &ServerRecord{Name: "creative", OwnerID: "owner1"} + api.External = staticExternal{p: fileOwner} + s := beginSession(t, api, 3) + + elsewhere := strings.Replace(sessionsRoute, "survival", "creative", 1) + "/" + s.ID + if w := do(api.ExternalHandler(), "GET", elsewhere, "", nil); w.Code != http.StatusNotFound || decodeErr(t, w) != "upload_not_found" { + t.Fatalf("another server: code = %d (%s)", w.Code, w.Body.String()) + } + + // Staff may reach the server, and still not someone else's session. + api.External = staticExternal{p: fileAdmin} + h := api.ExternalHandler() + for name, w := range map[string]*httptest.ResponseRecorder{ + "status": status(api, s.ID), + "part": doPart(h, partAt(s.ID, 0), "abc", 3), + "cancel": do(h, "DELETE", sessionsRoute+"/"+s.ID, "", nil), + "commit": commit(api, s.ID, `{}`), + } { + if w.Code != http.StatusNotFound || decodeErr(t, w) != "upload_not_found" { + t.Errorf("admin %s: code = %d (%s)", name, w.Code, w.Body.String()) + } + } + if files.calls != 0 { + t.Fatal("another account's commit reached the executor") + } + + api.External = staticExternal{p: fileOwner} + if w := status(api, s.ID); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 0 { + t.Fatalf("the owner's session was touched: code = %d (%s)", w.Code, w.Body.String()) + } + }) + + t.Run("a stranger is refused on every session route", func(t *testing.T) { + api, _, _, _ := mkFiles(t) + api.External = staticExternal{p: fileOwner} + s := beginSession(t, api, 3) + api.External = staticExternal{p: fileStranger} + h := api.ExternalHandler() + for name, w := range map[string]*httptest.ResponseRecorder{ + "begin": do(h, "POST", sessionsRoute+"?path=a.jar", `{"size":3}`, jsonHeader), + "status": status(api, s.ID), + "part": doPart(h, partAt(s.ID, 0), "abc", 3), + "cancel": do(h, "DELETE", sessionsRoute+"/"+s.ID, "", nil), + "commit": commit(api, s.ID, `{}`), + } { + if w.Code != http.StatusForbidden { + t.Errorf("%s: code = %d (%s)", name, w.Code, w.Body.String()) + } + } + }) + + t.Run("begin refused", func(t *testing.T) { + for _, c := range []struct { + name, target, body string + setup func(*API) + code int + errCode string + }{ + {"no size", sessionsRoute + "?path=a.jar", `{}`, nil, http.StatusBadRequest, "bad_request"}, + {"a negative size", sessionsRoute + "?path=a.jar", `{"size":-1}`, nil, http.StatusBadRequest, "bad_request"}, + {"no path", sessionsRoute, `{"size":3}`, nil, http.StatusBadRequest, "bad_request"}, + {"a path leaving the world", sessionsRoute + "?path=../a.jar", `{"size":3}`, nil, http.StatusBadRequest, "bad_path"}, + {"an absolute path", sessionsRoute + "?path=/etc/a.jar", `{"size":3}`, nil, http.StatusBadRequest, "bad_path"}, + {"the world folder itself", sessionsRoute + "?path=plugins/..", `{"size":3}`, nil, http.StatusBadRequest, "bad_path"}, + {"a staging disk at its floor", sessionsRoute + "?path=a.jar", `{"size":3}`, + func(a *API) { a.FileStage.MinFree = 1 }, http.StatusInsufficientStorage, "upload_staging_full"}, + {"no stage", sessionsRoute + "?path=a.jar", `{"size":3}`, + func(a *API) { a.FileStage = nil }, http.StatusServiceUnavailable, "files_unavailable"}, + {"no internal URL", sessionsRoute + "?path=a.jar", `{"size":3}`, + func(a *API) { a.InternalBaseURL = "" }, http.StatusServiceUnavailable, "files_unavailable"}, + {"a caller with no account", sessionsRoute + "?path=a.jar", `{"size":3}`, + func(a *API) { a.External = staticExternal{p: &Principal{Role: "admin", ViaAdminAccess: true}} }, + http.StatusForbidden, "forbidden"}, + } { + t.Run(c.name, func(t *testing.T) { + api, _, _, _ := mkFiles(t) + api.External = staticExternal{p: fileOwner} + if c.setup != nil { + c.setup(api) + } + w := do(api.ExternalHandler(), "POST", c.target, c.body, jsonHeader) + if w.Code != c.code || decodeErr(t, w) != c.errCode { + t.Fatalf("code = %d (%s), want %d %s", w.Code, w.Body.String(), c.code, c.errCode) + } + if api.FileStage != nil { + stageEmpty(t, api) + } + }) + } + }) + + t.Run("a fifth upload at once -> 429", func(t *testing.T) { + api, _, _, _ := mkFiles(t) + api.External = staticExternal{p: fileOwner} + for i := 0; i < fileedit.MaxSessionsPerUser; i++ { + beginSession(t, api, 1) + } + w := do(api.ExternalHandler(), "POST", sessionsRoute+"?path=a.jar", `{"size":1}`, jsonHeader) + if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "too_many_uploads" { + t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) + } + }) + + t.Run("a commit or cancel of no session -> 404", func(t *testing.T) { + api, _, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + const id = "00112233445566778899aabbccddeeff" + for name, w := range map[string]*httptest.ResponseRecorder{ + "commit": commit(api, id, `{}`), + "cancel": do(api.ExternalHandler(), "DELETE", sessionsRoute+"/"+id, "", nil), + "part": doPart(api.ExternalHandler(), partAt(id, 0), "abc", 3), + } { + if w.Code != http.StatusNotFound || decodeErr(t, w) != "upload_not_found" { + t.Errorf("%s: code = %d (%s)", name, w.Code, w.Body.String()) + } + } + if files.calls != 0 || len(cl.acquired) != 0 { + t.Fatalf("calls = %d acquired %v", files.calls, cl.acquired) + } + }) + + t.Run("a commit needs a body", func(t *testing.T) { + api, _, _, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + s := beginSession(t, api, 0) + if w := commit(api, s.ID, ""); w.Code != http.StatusBadRequest || files.calls != 0 { + t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String()) + } + }) +} + +// brokenWriter takes the headers and fails every write, as a connection that +// dropped once the answer began does. +type brokenWriter struct { + header http.Header + code int +} + +func (b *brokenWriter) Header() http.Header { return b.header } +func (b *brokenWriter) WriteHeader(code int) { b.code = code } +func (b *brokenWriter) Write(p []byte) (int, error) { return 0, errors.New("connection reset") } + +// TestFileOpsWorldGates pins the stopped and world-volume gates on the routes +// that begin or start a background op: each refuses before a byte is staged, a +// lock is asked for, or a Job is created. +func TestFileOpsWorldGates(t *testing.T) { + routes := []struct{ name, target, body string }{ + {"begin", sessionsRoute + "?path=a.jar", `{"size":3}`}, + {"commit", sessionsRoute + "/00112233445566778899aabbccddeeff/commit", `{}`}, + {"unzip", unzipRoute + "?path=maps/a.zip", `{}`}, + } + gates := []struct { + name, code string + set func(*fakeCluster) + }{ + {"running", "not_stopped", func(c *fakeCluster) { + c.byName["survival"].Ready = true + c.byName["survival"].DesiredState = string(v1alpha1.DesiredRunning) + }}, + {"starting", "not_stopped", func(c *fakeCluster) { + c.byName["survival"].DesiredState = string(v1alpha1.DesiredRunning) + }}, + {"no world volume", "no_world_volume", func(c *fakeCluster) { c.noWorld["survival"] = true }}, + } + for _, rt := range routes { + for _, g := range gates { + t.Run(rt.name+" on a "+g.name+" server", func(t *testing.T) { + api, _, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + g.set(cl) + w := do(api.ExternalHandler(), "POST", rt.target, rt.body, jsonHeader) + if w.Code != http.StatusConflict || decodeErr(t, w) != g.code { + t.Fatalf("code = %d (%s), want 409 %s", w.Code, w.Body.String(), g.code) + } + if files.calls != 0 || len(cl.acquired) != 0 { + t.Fatalf("calls = %d acquired %v", files.calls, cl.acquired) + } + stageEmpty(t, api) + }) + } + } +} + +func TestFileUnzip(t *testing.T) { + unzip := func(api *API, path, body string) *httptest.ResponseRecorder { + return do(api.ExternalHandler(), "POST", unzipRoute+"?path="+path, body, jsonHeader) + } + + t.Run("starts the Job under the world lock and audits it", func(t *testing.T) { + api, repo, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + files.op = fileedit.OpState{ID: "op2", Op: fileedit.OpUnzip, Path: "maps/a.zip", + State: fileedit.OpRunning, Started: opStarted} + w := unzip(api, "maps/a.zip", `{}`) + if w.Code != http.StatusAccepted { + t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) + } + want := map[string]any{"op": map[string]any{ + "id": "op2", "op": "unzip", "path": "maps/a.zip", "state": "running", + "started_at": "2026-09-28T10:00:00Z", "done": float64(0), "total": float64(0), + }} + if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) { + t.Fatalf("answer = %v, want %v", got, want) + } + if files.calls != 1 || files.gotOp != fileedit.OpUnzip || files.gotServer != "survival" || + files.gotPath != "maps/a.zip" || files.gotOverwrite { + t.Fatalf("executor saw calls=%d op %q server %q path %q overwrite %v", + files.calls, files.gotOp, files.gotServer, files.gotPath, files.gotOverwrite) + } + if strings.Join(cl.acquired, ",") != "survival:"+maintenance.KindFileWrite || strings.Join(cl.released, ",") != "survival" { + t.Fatalf("lock acquired %v, released %v", cl.acquired, cl.released) + } + onlyAudit(t, repo, "file.unzip", "survival:maps/a.zip", `{"overwrite":false}`) + }) + + t.Run("overwrite reaches the executor, and the suffix is any case", func(t *testing.T) { + api, repo, _, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + if w := unzip(api, "maps/A.ZIP", `{"overwrite":true}`); w.Code != http.StatusAccepted || !files.gotOverwrite || files.gotPath != "maps/A.ZIP" { + t.Fatalf("code = %d overwrite %v path %q (%s)", w.Code, files.gotOverwrite, files.gotPath, w.Body.String()) + } + onlyAudit(t, repo, "file.unzip", "survival:maps/A.ZIP", `{"overwrite":true}`) + }) + + t.Run("refused before the lock", func(t *testing.T) { + for _, c := range []struct { + name, path, body string + code int + errCode string + }{ + {"not a zip", "maps/a.tar.gz", `{}`, http.StatusBadRequest, "bad_path"}, + {"zip only inside the name", "maps/a.zip.bak", `{}`, http.StatusBadRequest, "bad_path"}, + {"no path", "", `{}`, http.StatusBadRequest, "bad_request"}, + {"no body", "maps/a.zip", "", http.StatusBadRequest, "bad_request"}, + } { + t.Run(c.name, func(t *testing.T) { + api, repo, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + w := unzip(api, c.path, c.body) + if w.Code != c.code || decodeErr(t, w) != c.errCode { + t.Fatalf("code = %d (%s), want %d %s", w.Code, w.Body.String(), c.code, c.errCode) + } + if files.calls != 0 || len(cl.acquired) != 0 || len(repo.audits) != 0 { + t.Fatalf("calls = %d acquired %v audits %+v", files.calls, cl.acquired, repo.audits) + } + }) + } + }) + + t.Run("a held world -> 409, no Job", func(t *testing.T) { + api, _, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + cl.maintErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindBackup} + if w := unzip(api, "maps/a.zip", `{}`); w.Code != http.StatusConflict || decodeErr(t, w) != "maintenance_in_progress" || files.calls != 0 { + t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String()) + } + }) + + t.Run("a Job that could not start is not audited and lets go of the world", func(t *testing.T) { + api, repo, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + files.err = errors.New("the cluster said no") + w := unzip(api, "maps/a.zip", `{}`) + if w.Code != http.StatusInternalServerError || len(repo.audits) != 0 || strings.Join(cl.released, ",") != "survival" { + t.Fatalf("code = %d audits %+v released %v (%s)", w.Code, repo.audits, cl.released, w.Body.String()) + } + }) + + t.Run("a stranger -> 403, an admin may", func(t *testing.T) { + api, _, _, files := mkFiles(t) + api.External = staticExternal{p: fileStranger} + if w := unzip(api, "maps/a.zip", `{}`); w.Code != http.StatusForbidden || files.calls != 0 { + t.Fatalf("stranger: code = %d calls = %d", w.Code, files.calls) + } + api.External = staticExternal{p: fileAdmin} + if w := unzip(api, "maps/a.zip", `{}`); w.Code != http.StatusAccepted || files.calls != 1 { + t.Fatalf("admin: code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String()) + } + }) +} + +func TestFileOps(t *testing.T) { + ended := opStarted.Add(3 * time.Minute) + list := func(api *API) *httptest.ResponseRecorder { + return do(api.ExternalHandler(), "GET", opsRoute, "", nil) + } + + t.Run("each state and failure as the API shows it", func(t *testing.T) { + api, _, _, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + base := func(id, op, state string) fileedit.OpState { + s := fileedit.OpState{ID: id, Op: op, Path: "maps/a.zip", State: state, Started: opStarted} + if state != fileedit.OpRunning { + s.Finished = ended + } + return s + } + running := base("a", fileedit.OpUnzip, fileedit.OpRunning) + running.Done, running.Total = 40, 100 + unzipped := base("b", fileedit.OpUnzip, fileedit.OpSucceeded) + unzipped.Done, unzipped.Total = 100, 100 + unzipped.Result = &fileedit.Result{Files: 7, Bytes: 100} + uploaded := base("c", fileedit.OpUpload, fileedit.OpSucceeded) + uploaded.Result = &fileedit.Result{SHA256: testSum} + exists := base("d", fileedit.OpUnzip, fileedit.OpFailed) + exists.Result = &fileedit.Result{Code: fileedit.CodeExists, Error: "2 files are already there", + Conflicts: []string{"maps/level.dat", "maps/r.0.0.mca"}, ConflictCount: 2} + full := base("e", fileedit.OpUpload, fileedit.OpFailed) + full.Result = &fileedit.Result{Code: fileedit.CodeNoSpace, Error: "no room", Need: 900, Avail: 100} + changed := base("f", fileedit.OpUpload, fileedit.OpFailed) + changed.Result = &fileedit.Result{Code: fileedit.CodeConflict, Error: "the bytes changed"} + unsafe := base("g", fileedit.OpUnzip, fileedit.OpFailed) + unsafe.Result = &fileedit.Result{Code: fileedit.CodeArchiveUnsafe, Error: "leaves the folder", Entry: "../x", + Files: 3, Bytes: 9} + deadline := base("h", fileedit.OpUnzip, fileedit.OpFailed) + deadline.Reason = "DeadlineExceeded" + killed := base("i", fileedit.OpUpload, fileedit.OpFailed) + killed.Reason = "BackoffLimitExceeded" + files.ops = []fileedit.OpState{running, unzipped, uploaded, exists, full, changed, unsafe, deadline, killed} + + w := list(api) + if w.Code != http.StatusOK || files.gotServer != "survival" { + t.Fatalf("code = %d server %q (%s)", w.Code, files.gotServer, w.Body.String()) + } + op := func(id, kind, state string, extra map[string]any) map[string]any { + m := map[string]any{"id": id, "op": kind, "path": "maps/a.zip", "state": state, + "started_at": "2026-09-28T10:00:00Z", "done": float64(0), "total": float64(0)} + if state != "running" { + m["finished_at"] = "2026-09-28T10:03:00Z" + } + for k, v := range extra { + m[k] = v + } + return m + } + failure := func(code, msg string, extra map[string]any) map[string]any { + m := map[string]any{"code": code, "message": msg} + for k, v := range extra { + m[k] = v + } + return map[string]any{"error": m} + } + want := map[string]any{"ops": []any{ + op("a", "unzip", "running", map[string]any{"done": float64(40), "total": float64(100)}), + op("b", "unzip", "succeeded", map[string]any{"done": float64(100), "total": float64(100), + "files": float64(7), "bytes": float64(100)}), + op("c", "upload", "succeeded", nil), + op("d", "unzip", "failed", failure("file_exists", "2 files are already there", map[string]any{ + "conflicts": []any{"maps/level.dat", "maps/r.0.0.mca"}, "conflict_count": float64(2)})), + op("e", "upload", "failed", failure("volume_full", "no room", map[string]any{ + "need": float64(900), "avail": float64(100)})), + op("f", "upload", "failed", failure("file_changed", "the bytes changed", nil)), + op("g", "unzip", "failed", failure("archive_unsafe", "leaves the folder", map[string]any{"entry": "../x"})), + op("h", "unzip", "failed", failure("job_failed", + "the file operation ran out of time (DeadlineExceeded); run it again", nil)), + op("i", "upload", "failed", failure("job_failed", + "the file operation stopped before it could report how it went (BackoffLimitExceeded); run it again", nil)), + }} + if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) { + gotJSON, _ := json.MarshalIndent(got, "", " ") + wantJSON, _ := json.MarshalIndent(want, "", " ") + t.Fatalf("ops =\n%s\nwant\n%s", gotJSON, wantJSON) + } + }) + + t.Run("none is an empty list", func(t *testing.T) { + api, _, _, _ := mkFiles(t) + api.External = staticExternal{p: fileOwner} + if w := list(api); w.Code != http.StatusOK || strings.TrimSpace(w.Body.String()) != `{"ops":[]}` { + t.Fatalf("code = %d %s", w.Code, w.Body.String()) + } + }) + + t.Run("answers while the server runs", func(t *testing.T) { + api, _, cl, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + cl.byName["survival"].Ready = true + cl.byName["survival"].DesiredState = string(v1alpha1.DesiredRunning) + cl.noWorld["survival"] = true + if w := list(api); w.Code != http.StatusOK || files.calls != 1 { + t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String()) + } + }) + + t.Run("who may look", func(t *testing.T) { + api, repo, _, files := mkFiles(t) + api.External = staticExternal{p: fileStranger} + if w := list(api); w.Code != http.StatusForbidden || files.calls != 0 { + t.Fatalf("stranger: code = %d calls = %d", w.Code, files.calls) + } + repo.byName["survival"].OwnerID = "someone-else" + api.External = staticExternal{p: fileAdmin} + if w := list(api); w.Code != http.StatusOK || files.calls != 1 { + t.Fatalf("admin: code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String()) + } + api.External = staticExternal{p: fileOwner} + if w := do(api.ExternalHandler(), "GET", strings.Replace(opsRoute, "survival", "missing", 1), "", nil); w.Code != http.StatusNotFound { + t.Fatalf("unknown server: code = %d", w.Code) + } + if w := do(api.ExternalHandler(), "GET", strings.Replace(opsRoute, "survival", "X", 1), "", nil); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_name" { + t.Fatalf("bad name: code = %d (%s)", w.Code, w.Body.String()) + } + }) + + t.Run("no executor -> 503, a failing one -> 500", func(t *testing.T) { + api, _, _, files := mkFiles(t) + api.External = staticExternal{p: fileOwner} + files.err = errors.New("the cluster said no") + if w := list(api); w.Code != http.StatusInternalServerError { + t.Fatalf("failing: code = %d (%s)", w.Code, w.Body.String()) + } + api.Files = nil + if w := list(api); w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" { + t.Fatalf("nil: code = %d (%s)", w.Code, w.Body.String()) + } + }) +} diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index b7cc5aa..e8e41a9 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -12,7 +12,6 @@ import ( "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/maintenance" - "felis.lolicon.best/internal/naming" ) // FileEditor is the server-file-editor surface the API depends on: list a @@ -39,18 +38,23 @@ import ( // 507 / 409. // // Read and Write return the file's SHA-256 (hex); Upload lands exactly the bytes -// src describes or fails. Write's expect is the +// src describes or fails. StartUpload and StartUnzip are the two that can run +// longer than a request: they start their Job and return, and Ops reports it +// (handlers_fileops.go). List also reports the room left on the volume. Write's expect is the // hash a client read the file at; when set, a file that changed since is refused // with ErrConflict instead of being overwritten. createOnly and a false overwrite // refuse an existing path with ErrExists. type FileEditor interface { - List(ctx context.Context, server, path string) (entries []fileedit.Entry, truncated bool, err error) + List(ctx context.Context, server, path string) (fileedit.Listing, error) Read(ctx context.Context, server, path string) (content []byte, sha256 string, err error) Write(ctx context.Context, server, path string, content []byte, expect string, createOnly bool) (sha256 string, err error) Mkdir(ctx context.Context, server, path string) error Delete(ctx context.Context, server, path string) error Rename(ctx context.Context, server, path, to string) error Upload(ctx context.Context, server, path string, src fileedit.UploadSource, overwrite bool) error + StartUpload(ctx context.Context, server, path string, src fileedit.UploadSource, overwrite bool) (fileedit.OpState, error) + StartUnzip(ctx context.Context, server, path string, overwrite bool) (fileedit.OpState, error) + Ops(ctx context.Context, server string) ([]fileedit.OpState, error) } // writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so @@ -96,16 +100,18 @@ func (a *API) handleListFiles(w http.ResponseWriter, r *http.Request) { } path := r.URL.Query().Get("path") - entries, truncated, err := a.Files.List(r.Context(), name, path) + ls, err := a.Files.List(r.Context(), name, path) if err != nil { writeFileEditError(w, r, err) return } - if entries == nil { - entries = []fileedit.Entry{} // an empty directory is [], never null + if ls.Entries == nil { + ls.Entries = []fileedit.Entry{} // an empty directory is [], never null } + // free_bytes lets the panel refuse an upload the volume cannot take before + // sending any of it; the Job that lands it checks again. writeJSON(w, http.StatusOK, map[string]any{ - "path": path, "entries": entries, "truncated": truncated, + "path": path, "entries": ls.Entries, "truncated": ls.Truncated, "free_bytes": ls.Free, }) } @@ -351,7 +357,8 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) { } if r.ContentLength > fileedit.MaxUploadBytes { writeError(w, r, newError(http.StatusRequestEntityTooLarge, "too_large", - "the file is %d bytes; uploads are at most %d", r.ContentLength, fileedit.MaxUploadBytes)) + "the file is %d bytes; one request carries at most %d, so send it as an upload session (POST files/uploads)", + r.ContentLength, fileedit.MaxUploadBytes)) return } overwrite := r.URL.Query().Get("overwrite") == "true" @@ -397,7 +404,8 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) { } // handleInternalFileUpload serves GET /api/v1/internal/file-uploads/{id} — the -// staged bytes of one upload, to the one Job created to land them. It is Public on +// staged bytes of one upload, to the one Job created to land them. A file sent +// in parts (handlers_fileops.go) is served the same way. It is Public on // the internal face: the Job holds no service token (it holds no credential at // all), so the bearer token minted with the upload is the whole check, and it // opens that upload once. An unknown id, a wrong token and a spent one are the @@ -421,7 +429,11 @@ func (a *API) handleInternalFileUpload(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) w.WriteHeader(http.StatusOK) - _, _ = io.Copy(w, f) + // A session sent whole is on the Job's side now; one cut short stays, so + // committing it again does not mean sending it again. + if n, err := io.Copy(w, f); err == nil && n == size { + a.FileStage.Served(r.PathValue("id")) + } } // auditFile records a file change. The target is ":", as file.write @@ -463,20 +475,8 @@ var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`) // It returns the validated server name and false if it has already written a // response. func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, bool) { - name := r.PathValue("name") - if err := naming.ValidateServerName(name); err != nil { - writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name: %v", err)) - return "", false - } - - p := principalFromContext(r.Context()) - rec, err := a.Repo.ServerByName(r.Context(), name) - if err != nil { - a.writeLookupError(w, r, err) - return "", false - } - if !a.isOwnerOrAdmin(p, rec) { - writeError(w, r, errForbidden) + name, ok := a.authorizeServerFiles(w, r) // ① ② ③ + if !ok { return "", false } diff --git a/internal/api/handlers_files_test.go b/internal/api/handlers_files_test.go index e429cbf..df3db49 100644 --- a/internal/api/handlers_files_test.go +++ b/internal/api/handlers_files_test.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "testing" + "time" "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/fileedit" @@ -42,14 +43,19 @@ type fakeFileEditor struct { entries []fileedit.Entry truncated bool + free int64 content []byte sum string + + // op is what StartUpload and StartUnzip answer (started), ops what Ops does. + op fileedit.OpState + ops []fileedit.OpState } -func (f *fakeFileEditor) List(_ context.Context, server, path string) ([]fileedit.Entry, bool, error) { +func (f *fakeFileEditor) List(_ context.Context, server, path string) (fileedit.Listing, error) { f.calls++ f.gotServer, f.gotPath = server, path - return f.entries, f.truncated, f.err + return fileedit.Listing{Entries: f.entries, Truncated: f.truncated, Free: f.free}, f.err } func (f *fakeFileEditor) Read(_ context.Context, server, path string) ([]byte, string, error) { @@ -92,6 +98,33 @@ func (f *fakeFileEditor) Upload(_ context.Context, server, path string, src file return f.err } +func (f *fakeFileEditor) StartUpload(_ context.Context, server, path string, src fileedit.UploadSource, overwrite bool) (fileedit.OpState, error) { + f.calls++ + f.gotOp, f.gotServer, f.gotPath, f.gotSource, f.gotOverwrite = fileedit.OpUpload, server, path, src, overwrite + return f.started(fileedit.OpUpload, path), f.err +} + +func (f *fakeFileEditor) StartUnzip(_ context.Context, server, path string, overwrite bool) (fileedit.OpState, error) { + f.calls++ + f.gotOp, f.gotServer, f.gotPath, f.gotOverwrite = fileedit.OpUnzip, server, path, overwrite + return f.started(fileedit.OpUnzip, path), f.err +} + +// started is the op StartUpload and StartUnzip answer: f.op when a test set +// one, else a running op as the real Editor answers it. +func (f *fakeFileEditor) started(op, path string) fileedit.OpState { + if f.op.ID != "" { + return f.op + } + return fileedit.OpState{ID: "op" + strconv.Itoa(f.calls), Op: op, Path: path, State: fileedit.OpRunning, Started: time.Now()} +} + +func (f *fakeFileEditor) Ops(_ context.Context, server string) ([]fileedit.OpState, error) { + f.calls++ + f.gotServer = server + return f.ops, f.err +} + // fileRouteHeader is the Content-Type a file route's body goes with: raw bytes // for an upload, JSON for any other body. func fileRouteHeader(name, body string) map[string]string { @@ -340,6 +373,7 @@ func TestFileEditorHandlers(t *testing.T) { api, _, _, files := mkFiles(t) files.entries = []fileedit.Entry{{Name: "paper.yml", Size: 12}, {Name: "sub", IsDir: true}} files.truncated = true + files.free = 5 << 30 api.External = staticExternal{p: owner} w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files?path=config", "", nil) @@ -350,6 +384,7 @@ func TestFileEditorHandlers(t *testing.T) { Path string `json:"path"` Entries []fileedit.Entry `json:"entries"` Truncated bool `json:"truncated"` + FreeBytes int64 `json:"free_bytes"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) @@ -357,7 +392,7 @@ func TestFileEditorHandlers(t *testing.T) { if files.gotPath != "config" { t.Fatalf("executor saw path %q, want the query value verbatim", files.gotPath) } - if resp.Path != "config" || len(resp.Entries) != 2 || !resp.Truncated { + if resp.Path != "config" || len(resp.Entries) != 2 || !resp.Truncated || resp.FreeBytes != 5<<30 { t.Fatalf("unexpected response %+v", resp) } }) diff --git a/internal/api/jobstatus.go b/internal/api/jobstatus.go index 62cab50..e1bafaf 100644 --- a/internal/api/jobstatus.go +++ b/internal/api/jobstatus.go @@ -14,7 +14,7 @@ import ( // object an operator with kubectl could read. The route is the API-side outlet. type AsyncJob struct { Name string `json:"name"` - Kind string `json:"kind"` // "backup" | "restore" | "export_world" | "export_backup" + Kind string `json:"kind"` // "backup" | "restore" | "export_world" | "export_backup" | "export_files" State string `json:"state"` // "running" | "succeeded" | "failed" Message string `json:"message,omitempty"` StartedAt time.Time `json:"started_at,omitzero"` diff --git a/internal/api/k8sjobstatus.go b/internal/api/k8sjobstatus.go index 4e05702..d2acaf3 100644 --- a/internal/api/k8sjobstatus.go +++ b/internal/api/k8sjobstatus.go @@ -230,11 +230,15 @@ func jobOutcome(j *batchv1.Job) (AsyncJob, bool) { case jobManagedByRestore: kind = "restore" case jobManagedByExport: - // As maintenance.JobKind reads it: only a Job that says it reads a - // backup is not a world export. - kind = "export_world" - if j.Labels[maintenance.LabelExportMode] == maintenance.ExportModeBackup { + // As maintenance.JobKind reads it: a Job that names no mode this build + // knows reads as a world export, the kind that holds the world. + switch j.Labels[maintenance.LabelExportMode] { + case maintenance.ExportModeBackup: kind = "export_backup" + case maintenance.ExportModeFiles: + kind = "export_files" + default: + kind = "export_world" } default: return AsyncJob{}, false diff --git a/internal/api/openapi_parity_test.go b/internal/api/openapi_parity_test.go index e8159ce..3c9af94 100644 --- a/internal/api/openapi_parity_test.go +++ b/internal/api/openapi_parity_test.go @@ -42,6 +42,9 @@ func TestOpenAPISchemasMatchWireStructs(t *testing.T) { "BackupView": BackupView{}, "ExportTicket": exportTicketView{}, "ExportStatus": exportStatusView{}, + "FileUploadSession": fileSessionView{}, + "FileOp": fileOpView{}, + "FileOpError": fileOpError{}, "Schedule": Schedule{}, "Build": build.Build{}, "Image": build.Image{}, diff --git a/internal/backup/export.go b/internal/backup/export.go new file mode 100644 index 0000000..60cb89b --- /dev/null +++ b/internal/backup/export.go @@ -0,0 +1,126 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "io/fs" + "os" +) + +// Filter decides, for one regular file on its way out of a world into an +// export, whether it is left out (withhold) or has its bytes replaced (rewrite +// non-nil). name is the archive path; info is the file's, or nil when the file +// is an entry of a stored archive, which has only names. A backup is not +// filtered: it stays on the platform, and a restore must bring the world back +// whole. +type Filter func(name string, info fs.FileInfo) (withhold bool, rewrite func([]byte) []byte) + +// maxRewrite bounds a file a Filter rewrites, which is read whole into memory. +// The files it rewrites are small configs; one larger than this is withheld +// rather than sent unrewritten. +const maxRewrite = 1 << 20 + +// WriteTarGz archives srcDir into w laid out exactly as Archive lays out a +// backup, so an exported world restores like any other archive. It returns the +// entries a tar cannot hold and the files filter withheld. The world export Job +// streams it straight into its upload. +func WriteTarGz(ctx context.Context, w io.Writer, srcDir string, filter Filter) (skipped, withheld []string, err error) { + st, err := writeTarGz(ctx, w, srcDir, filter) + return st.skipped, st.withheld, err +} + +// FilterTarGz copies the gzip+tar archive read from r into w, passing every +// regular file through filter by name. It is how a stored backup leaves the +// platform: the archive is the world as it was, secrets included, so it is +// re-written on the way out rather than handed over as stored. +// +// r is read to its very end, past the tar trailer, before w's archive is +// closed. A reader that checks a digest when it reaches EOF therefore fails the +// copy while w still lacks the end of its archive, and a receiver never holds a +// complete-looking copy of a corrupt backup. +func FilterTarGz(ctx context.Context, w io.Writer, r io.Reader, filter Filter) (withheld []string, err error) { + zr, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("backup: open archive: %w", err) + } + tr := tar.NewReader(zr) + zw := gzip.NewWriter(w) + tw := tar.NewWriter(zw) + for { + if err := ctx.Err(); err != nil { + return withheld, err + } + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return withheld, fmt.Errorf("backup: read archive: %w", err) + } + if !hdr.FileInfo().Mode().IsRegular() { + if err := tw.WriteHeader(hdr); err != nil { + return withheld, err + } + continue + } + withhold, rewrite := filter(hdr.Name, nil) + if withhold { + withheld = append(withheld, hdr.Name) + continue + } + if rewrite == nil { + if err := tw.WriteHeader(hdr); err != nil { + return withheld, err + } + if _, err := io.Copy(tw, tr); err != nil { + return withheld, fmt.Errorf("backup: copy %s: %w", hdr.Name, err) + } + continue + } + content, err := io.ReadAll(io.LimitReader(tr, maxRewrite+1)) + if err != nil { + return withheld, fmt.Errorf("backup: read %s: %w", hdr.Name, err) + } + if len(content) > maxRewrite { + withheld = append(withheld, hdr.Name) + continue + } + content = rewrite(content) + hdr.Size = int64(len(content)) + if err := tw.WriteHeader(hdr); err != nil { + return withheld, err + } + if _, err := tw.Write(content); err != nil { + return withheld, err + } + } + // Through the gzip trailer to r's EOF (see above). + if _, err := io.Copy(io.Discard, zr); err != nil { + return withheld, fmt.Errorf("backup: read archive: %w", err) + } + if err := tw.Close(); err != nil { + return withheld, fmt.Errorf("backup: close tar: %w", err) + } + if err := zw.Close(); err != nil { + return withheld, fmt.Errorf("backup: close gzip: %w", err) + } + return withheld, nil +} + +// readRewritable reads a file a Filter rewrites, reporting false when it is +// larger than maxRewrite. +func readRewritable(path string) ([]byte, bool, error) { + f, err := os.Open(path) + if err != nil { + return nil, false, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, maxRewrite+1)) + if err != nil { + return nil, false, err + } + return b, len(b) <= maxRewrite, nil +} diff --git a/internal/backup/export_test.go b/internal/backup/export_test.go new file mode 100644 index 0000000..9c276a0 --- /dev/null +++ b/internal/backup/export_test.go @@ -0,0 +1,235 @@ +package backup + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// testFilter withholds secret.yml and rewrites props.txt, by name. +func testFilter(name string, _ fs.FileInfo) (bool, func([]byte) []byte) { + switch filepath.Base(name) { + case "secret.yml": + return true, nil + case "props.txt": + return false, summarize + } + return false, nil +} + +// summarize is a rewrite that changes the length, so a header left with the +// old size shows: the first bytes upper-cased, then how many there were. +func summarize(b []byte) []byte { + return fmt.Appendf(nil, "%s (%d bytes)", bytes.ToUpper(b[:min(len(b), 8)]), len(b)) +} + +// untar reads a gzip+tar stream into name → content ("" for folders, +// "-> target" for symbolic links). +func untar(t *testing.T, b []byte) map[string]string { + t.Helper() + zr, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(zr) + got := map[string]string{} + for { + hdr, err := tr.Next() + if err == io.EOF { + return got + } + if err != nil { + t.Fatal(err) + } + switch hdr.Typeflag { + case tar.TypeDir: + got[hdr.Name] = "" + continue + case tar.TypeSymlink: + got[hdr.Name] = "-> " + hdr.Linkname + continue + } + body, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + if int64(len(body)) != hdr.Size { + t.Fatalf("%s: header says %d bytes, body has %d", hdr.Name, hdr.Size, len(body)) + } + got[hdr.Name] = string(body) + } +} + +func TestWriteTarGzFilter(t *testing.T) { + src := t.TempDir() + for name, body := range map[string]string{ + "keep.txt": "kept", + "props.txt": "rcon=x", + "conf/secret.yml": "key", + "conf/big/props.txt": strings.Repeat("a", maxRewrite+1), + "edge/props.txt": strings.Repeat("a", maxRewrite), + } { + p := filepath.Join(src, name) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink("keep.txt", filepath.Join(src, "link")); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + skipped, withheld, err := WriteTarGz(context.Background(), &out, src, testFilter) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + "keep.txt": "kept", "props.txt": "RCON=X (6 bytes)", + "conf/": "", "conf/big/": "", + // Exactly maxRewrite bytes still fits. + "edge/": "", "edge/props.txt": "AAAAAAAA (1048576 bytes)", + } + if got := untar(t, out.Bytes()); !reflect.DeepEqual(got, want) { + t.Fatalf("archive = %v\nwant %v", got, want) + } + if !reflect.DeepEqual(skipped, []string{"link"}) { + t.Errorf("skipped = %v, want [link]", skipped) + } + // An oversized file the filter would rewrite goes out withheld, never as is. + if !reflect.DeepEqual(withheld, []string{"conf/big/props.txt", "conf/secret.yml"}) { + t.Errorf("withheld = %v", withheld) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, _, err := WriteTarGz(ctx, io.Discard, src, testFilter); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled: err = %v, want context.Canceled", err) + } + + // No filter keeps everything: the backup path. + out.Reset() + if _, withheld, err := WriteTarGz(context.Background(), &out, src, nil); err != nil || withheld != nil { + t.Fatalf("unfiltered: withheld %v, err %v", withheld, err) + } + if got := untar(t, out.Bytes()); got["props.txt"] != "rcon=x" || got["conf/secret.yml"] != "key" { + t.Fatalf("unfiltered archive changed files: %v", got) + } +} + +// storedArchive is a gzip+tar like one Archive writes. +func storedArchive(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + zw := gzip.NewWriter(&buf) + tw := tar.NewWriter(zw) + add := func(hdr *tar.Header, body string) { + hdr.Size = int64(len(body)) + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatal(err) + } + } + add(&tar.Header{Name: "conf/", Typeflag: tar.TypeDir, Mode: 0o755}, "") + add(&tar.Header{Name: "conf/secret.yml", Typeflag: tar.TypeReg, Mode: 0o600}, "key") + add(&tar.Header{Name: "props.txt", Typeflag: tar.TypeReg, Mode: 0o644}, "rcon=x") + add(&tar.Header{Name: "world/level.dat", Typeflag: tar.TypeReg, Mode: 0o600}, "level") + add(&tar.Header{Name: "big/props.txt", Typeflag: tar.TypeReg, Mode: 0o644}, strings.Repeat("a", maxRewrite+1)) + add(&tar.Header{Name: "edge/props.txt", Typeflag: tar.TypeReg, Mode: 0o644}, strings.Repeat("a", maxRewrite)) + // A link holds no bytes, so it passes whatever its name. + add(&tar.Header{Name: "old/secret.yml", Typeflag: tar.TypeSymlink, Linkname: "../world/level.dat", Mode: 0o777}, "") + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// failAtEOF passes r through and turns its EOF into err, as the export Job's +// digest check does on a mismatch. +type failAtEOF struct { + r io.Reader + err error + read int +} + +func (f *failAtEOF) Read(p []byte) (int, error) { + n, err := f.r.Read(p) + f.read += n + if err == io.EOF { + return n, f.err + } + return n, err +} + +func TestFilterTarGz(t *testing.T) { + stored := storedArchive(t) + + t.Run("withholds and rewrites by name, keeps the rest", func(t *testing.T) { + var out bytes.Buffer + withheld, err := FilterTarGz(context.Background(), &out, bytes.NewReader(stored), testFilter) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + "conf/": "", "props.txt": "RCON=X (6 bytes)", "world/level.dat": "level", + "edge/props.txt": "AAAAAAAA (1048576 bytes)", "old/secret.yml": "-> ../world/level.dat", + } + if got := untar(t, out.Bytes()); !reflect.DeepEqual(got, want) { + t.Fatalf("archive = %v\nwant %v", got, want) + } + if !reflect.DeepEqual(withheld, []string{"conf/secret.yml", "big/props.txt"}) { + t.Errorf("withheld = %v", withheld) + } + }) + + t.Run("an error at the end of the input leaves the output unfinished", func(t *testing.T) { + bad := errors.New("digest mismatch") + src := &failAtEOF{r: bytes.NewReader(stored), err: bad} + var out bytes.Buffer + if _, err := FilterTarGz(context.Background(), &out, src, testFilter); !errors.Is(err, bad) { + t.Fatalf("err = %v, want the end-of-input error", err) + } + if src.read != len(stored) { + t.Fatalf("read %d of %d input bytes", src.read, len(stored)) + } + zr, err := gzip.NewReader(bytes.NewReader(out.Bytes())) + if err == nil { + _, err = io.ReadAll(zr) + } + if err == nil { + t.Fatal("the output is a complete gzip stream; it must lack its end") + } + }) + + t.Run("a cancelled copy stops", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := FilterTarGz(ctx, io.Discard, bytes.NewReader(stored), testFilter); err != context.Canceled { + t.Fatalf("err = %v, want context.Canceled", err) + } + }) + + t.Run("not an archive", func(t *testing.T) { + var out bytes.Buffer + if _, err := FilterTarGz(context.Background(), &out, strings.NewReader("plain text"), testFilter); err == nil { + t.Fatal("a non-gzip input was accepted") + } + }) +} diff --git a/internal/backup/tarlocal.go b/internal/backup/tarlocal.go index 87b4905..488c649 100644 --- a/internal/backup/tarlocal.go +++ b/internal/backup/tarlocal.go @@ -79,7 +79,7 @@ func (t *TarLocal) Archive(ctx context.Context, server, pvc string) (Archived, e return Archived{}, fmt.Errorf("backup: create archive: %w", err) } h := sha256.New() - st, err := writeTarGz(ctx, io.MultiWriter(f, h), srcDir) + st, err := writeTarGz(ctx, io.MultiWriter(f, h), srcDir, nil) if err == nil { if err = f.Sync(); err != nil { err = fmt.Errorf("backup: sync archive: %w", err) @@ -349,17 +349,20 @@ func (t *TarLocal) Delete(_ context.Context, ref ArchiveRef) error { return nil } -// tarStats is what writeTarGz put in the archive and what it left out. +// tarStats is what writeTarGz put in the archive and what it left out: skipped +// are entries a tar cannot hold, withheld the files the filter kept back. type tarStats struct { - entries int - skipped []string + entries int + skipped []string + withheld []string } // writeTarGz archives srcDir (not the root entry itself) as gzip+tar. Each entry // keeps its permission bits, without setuid, setgid and sticky, and its // modification time. Entries other than regular files and directories are left -// out and listed in the stats. -func writeTarGz(ctx context.Context, w io.Writer, srcDir string) (tarStats, error) { +// out and listed in the stats. A non-nil filter sees every regular file (see +// Filter); a backup passes nil and keeps everything. +func writeTarGz(ctx context.Context, w io.Writer, srcDir string, filter Filter) (tarStats, error) { var st tarStats gz := gzip.NewWriter(w) tw := tar.NewWriter(gz) @@ -392,6 +395,34 @@ func writeTarGz(ctx context.Context, w io.Writer, srcDir string) (tarStats, erro st.entries++ return nil case info.Mode().IsRegular(): + var rewrite func([]byte) []byte + if filter != nil { + var withhold bool + if withhold, rewrite = filter(name, info); withhold { + st.withheld = append(st.withheld, name) + return nil + } + } + if rewrite != nil { + content, ok, err := readRewritable(path) + if err != nil { + return err + } + if !ok { + st.withheld = append(st.withheld, name) + return nil + } + content = rewrite(content) + hdr := &tar.Header{Name: name, Mode: mode, ModTime: info.ModTime(), Size: int64(len(content)), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if _, err := tw.Write(content); err != nil { + return err + } + st.entries++ + return nil + } hdr := &tar.Header{Name: name, Mode: mode, ModTime: info.ModTime(), Size: info.Size(), Typeflag: tar.TypeReg} if err := tw.WriteHeader(hdr); err != nil { return err @@ -426,14 +457,6 @@ func writeTarGz(ctx context.Context, w io.Writer, srcDir string) (tarStats, erro return st, nil } -// WriteTarGz archives srcDir into w laid out exactly as Archive lays out a -// backup, so an exported world restores like any other archive, and returns the -// entries it left out. The world export Job streams it straight into its upload. -func WriteTarGz(ctx context.Context, w io.Writer, srcDir string) ([]string, error) { - st, err := writeTarGz(ctx, w, srcDir) - return st.skipped, err -} - // dirMeta is a directory's recorded permission bits and modification time, // applied once nothing more is written into it. type dirMeta struct { diff --git a/internal/fileedit/download.go b/internal/fileedit/download.go new file mode 100644 index 0000000..f5b44bf --- /dev/null +++ b/internal/fileedit/download.go @@ -0,0 +1,215 @@ +package fileedit + +import ( + "archive/zip" + "bytes" + "compress/flate" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" +) + +// Content types of a download: a file goes out as its bytes, a folder as a zip. +const ( + DownloadFileType = "application/octet-stream" + DownloadZipType = "application/zip" +) + +// Download is one file or folder of a world on its way to the owner's browser, +// run by the export Job (cmd/felis export --mode files). It passes the same +// guards a read does (Guard): the forwarding-secret file never leaves, and +// server.properties leaves with its RCON password redacted. +type Download struct { + // Size is a file download's exact length, or -1 for a folder, whose zip is + // written as it streams. + Size int64 + ContentType string + // Skipped and Withheld count, once WriteTo has run, the entries a folder + // download left out: links, devices and sockets, and guarded files. + Skipped, Withheld int + + root *os.Root + name string + file *os.File // a file download + body []byte // a redacted file download + guard Guard +} + +// OpenDownload opens name under rootPath for download. dir is what the caller +// saw at name when it asked (the panel's listing): a download of a file that has +// since become a folder, or the reverse, is refused rather than sent as the +// other thing. The world root itself is refused; the world export sends that. +func OpenDownload(rootPath, name string, dir bool) (*Download, error) { + name = path.Clean(name) + if name == "." || name == "/" || !fs.ValidPath(name) { + return nil, fmt.Errorf("%s is not a file or folder inside the world", name) + } + r, err := os.OpenRoot(rootPath) + if err != nil { + return nil, fmt.Errorf("open the world root: %w", err) + } + d := &Download{root: r, name: name, guard: NewGuard(r)} + if err := d.open(dir); err != nil { + r.Close() + return nil, err + } + return d, nil +} + +func (d *Download) open(dir bool) error { + info, err := d.root.Stat(d.name) + if err != nil { + return err + } + if info.IsDir() != dir { + if info.IsDir() { + return fmt.Errorf("%s is a folder now; reload the file list and download it again", d.name) + } + return fmt.Errorf("%s is not a folder now; reload the file list and download it again", d.name) + } + if dir { + d.Size, d.ContentType = -1, DownloadZipType + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file", d.name) + } + withhold, redact := d.guard.Rule(info) + if withhold { + return fmt.Errorf("%s is the file holding the proxy forwarding secret, which is shared cluster-wide, and cannot be downloaded", d.name) + } + f, err := d.root.Open(d.name) + if err != nil { + return err + } + d.ContentType = DownloadFileType + if !redact { + d.file, d.Size = f, info.Size() + return nil + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, MaxReadBytes+1)) + if err != nil { + return err + } + if len(b) > MaxReadBytes { + return fmt.Errorf("%s is over %d bytes and cannot be redacted for download", d.name, MaxReadBytes) + } + d.body = RedactProps(b) + d.Size = int64(len(d.body)) + return nil +} + +// Close releases what OpenDownload opened. +func (d *Download) Close() error { + if d.file != nil { + d.file.Close() + } + return d.root.Close() +} + +// WriteTo writes the download to w: exactly Size bytes of a file, or a zip of a +// folder whose entries sit under the folder's own name, so unpacking it makes +// that one folder. A file that shrank since it was opened is an error, never a +// short download passed off as whole. +func (d *Download) WriteTo(ctx context.Context, w io.Writer) error { + switch { + case d.body != nil: + _, err := w.Write(d.body) + return err + case d.file != nil: + _, err := io.CopyN(w, ctxReader{ctx, d.file}, d.Size) + if errors.Is(err, io.EOF) { + return fmt.Errorf("%s shrank while it was being downloaded", d.name) + } + return err + } + return d.writeZip(ctx, w) +} + +// writeZip streams the folder as a zip. Everything is deflated at the fastest +// level: the Job has one CPU and the owner's connection is the slower end, and +// already-compressed files (jars, region files) come out as stored blocks +// without costing much. Links, devices and sockets are left out, like a world +// export leaves them out. +func (d *Download) writeZip(ctx context.Context, w io.Writer) error { + zw := zip.NewWriter(w) + zw.RegisterCompressor(zip.Deflate, func(out io.Writer) (io.WriteCloser, error) { + return flate.NewWriter(out, flate.BestSpeed) + }) + base := path.Base(d.name) + err := fs.WalkDir(d.root.FS(), d.name, func(p string, de fs.DirEntry, err error) error { + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + entry := base + p[len(d.name):] + info, err := de.Info() + if err != nil { + return err + } + switch { + case de.IsDir(): + hdr := &zip.FileHeader{Name: entry + "/", Modified: info.ModTime()} + hdr.SetMode(info.Mode().Perm() | fs.ModeDir) + _, err := zw.CreateHeader(hdr) + return err + case !de.Type().IsRegular(): + d.Skipped++ + return nil + } + withhold, redact := d.guard.Rule(info) + if withhold { + d.Withheld++ + return nil + } + f, err := d.root.Open(p) + if err != nil { + return err + } + defer f.Close() + var src io.Reader = ctxReader{ctx, f} + if redact { + b, err := io.ReadAll(io.LimitReader(f, MaxReadBytes+1)) + if err != nil { + return err + } + if len(b) > MaxReadBytes { + d.Withheld++ + return nil + } + src = bytes.NewReader(RedactProps(b)) + } + hdr := &zip.FileHeader{Name: entry, Method: zip.Deflate, Modified: info.ModTime()} + hdr.SetMode(info.Mode().Perm()) + fw, err := zw.CreateHeader(hdr) + if err != nil { + return err + } + _, err = io.Copy(fw, src) + return err + }) + if err != nil { + return err + } + return zw.Close() +} + +// ctxReader stops a long copy once ctx is done. +type ctxReader struct { + ctx context.Context + r io.Reader +} + +func (c ctxReader) Read(p []byte) (int, error) { + if err := c.ctx.Err(); err != nil { + return 0, err + } + return c.r.Read(p) +} diff --git a/internal/fileedit/download_test.go b/internal/fileedit/download_test.go new file mode 100644 index 0000000..4bf13da --- /dev/null +++ b/internal/fileedit/download_test.go @@ -0,0 +1,304 @@ +package fileedit + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" +) + +// downloadWorld is worldRoot plus the guarded files with real secrets in them. +func downloadWorld(t *testing.T) string { + t.Helper() + root, _ := worldRoot(t) + for name, body := range map[string]string{ + "server.properties": "motd=hi\nrcon.password=hunter2\n", + "config/paper-global.yml": "secret: aVeryRealForwardingKey\n", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +// download runs a whole download into memory. +func download(t *testing.T, root, name string, dir bool) (*Download, []byte) { + t.Helper() + d, err := OpenDownload(root, name, dir) + if err != nil { + t.Fatalf("OpenDownload(%s): %v", name, err) + } + defer d.Close() + var out bytes.Buffer + if err := d.WriteTo(context.Background(), &out); err != nil { + t.Fatalf("WriteTo(%s): %v", name, err) + } + return d, out.Bytes() +} + +// unzipped reads a zip into name → content (" " for folders). +func unzipped(t *testing.T, b []byte) map[string]string { + t.Helper() + zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, f := range zr.File { + if f.FileInfo().IsDir() { + // The Unix mode, S_IFDIR included, which is what unzip tools + // restore a folder's permissions from. + got[f.Name] = fmt.Sprintf("", f.ExternalAttrs>>16) + continue + } + rc, err := f.Open() + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatal(err) + } + got[f.Name] = f.Mode().Perm().String() + " " + string(body) + } + return got +} + +func TestDownload(t *testing.T) { + const redacted = "motd=hi\nrcon.password=" + redactedValue + "\n" + + t.Run("a file goes out as its exact bytes", func(t *testing.T) { + root := downloadWorld(t) + d, got := download(t, root, "config/paper.yml", false) + if string(got) != "verbose: false\n" || d.Size != int64(len(got)) || d.ContentType != DownloadFileType { + t.Fatalf("download = %q, size %d, type %s", got, d.Size, d.ContentType) + } + }) + + t.Run("server.properties, under any name, goes out redacted", func(t *testing.T) { + root := downloadWorld(t) + if err := os.Link(filepath.Join(root, "server.properties"), filepath.Join(root, "copy.txt")); err != nil { + t.Fatal(err) + } + symlink(t, "server.properties", filepath.Join(root, "sym.txt")) + for _, name := range []string{"server.properties", "./server.properties", "copy.txt", "sym.txt"} { + d, got := download(t, root, name, false) + if string(got) != redacted || d.Size != int64(len(redacted)) { + t.Errorf("%s: download = %q, size %d; want %q", name, got, d.Size, redacted) + } + } + }) + + t.Run("the forwarding secret, under any name, is refused", func(t *testing.T) { + root := downloadWorld(t) + if err := os.Link(filepath.Join(root, "config/paper-global.yml"), filepath.Join(root, "hard.yml")); err != nil { + t.Fatal(err) + } + symlink(t, "config", filepath.Join(root, "cfg")) + for _, name := range []string{"config/paper-global.yml", "hard.yml", "cfg/paper-global.yml"} { + d, err := OpenDownload(root, name, false) + if err == nil { + d.Close() + t.Errorf("%s: opened for download", name) + } else if !strings.Contains(err.Error(), "forwarding secret") { + t.Errorf("%s: err = %v", name, err) + } + } + }) + + // A guarded name that is itself a link guards what it points at: that file + // is what the server reads, under whatever name it is reached. + t.Run("a guarded name that is a link guards its target", func(t *testing.T) { + root, _ := worldRoot(t) + for name, body := range map[string]string{ + "config/real.yml": "secret: aVeryRealForwardingKey\n", + "real.properties": "motd=hi\nrcon.password=hunter2\n", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.Remove(filepath.Join(root, "server.properties")); err != nil { + t.Fatal(err) + } + symlink(t, "real.yml", filepath.Join(root, "config/paper-global.yml")) + symlink(t, "real.properties", filepath.Join(root, "server.properties")) + if d, err := OpenDownload(root, "config/real.yml", false); err == nil { + d.Close() + t.Error("the forwarding secret opened under its link target's name") + } + if _, got := download(t, root, "real.properties", false); string(got) != redacted { + t.Errorf("real.properties = %q, want %q", got, redacted) + } + }) + + t.Run("what is not there as the listing said is refused", func(t *testing.T) { + root := downloadWorld(t) + // Opening a FIFO for reading would wait for a writer that never comes. + if err := syscall.Mkfifo(filepath.Join(root, "pipe"), 0o644); err != nil { + t.Fatal(err) + } + for _, c := range []struct { + name string + dir bool + want string + }{ + {"config", false, "is a folder now"}, + {"server.properties", true, "is not a folder now"}, + {"missing.txt", false, "no such file"}, + {"pipe", false, "not a regular file"}, + {".", true, "not a file or folder inside"}, + {"", true, "not a file or folder inside"}, + {"../outside", true, "not a file or folder inside"}, + {"/etc", true, "not a file or folder inside"}, + } { + d, err := OpenDownload(root, c.name, c.dir) + if err == nil { + d.Close() + t.Errorf("%q: opened", c.name) + } else if !strings.Contains(err.Error(), c.want) { + t.Errorf("%q: err = %v, want %q", c.name, err, c.want) + } + } + }) + + t.Run("a folder goes out as a zip under its own name, guarded", func(t *testing.T) { + root := downloadWorld(t) + plugins := filepath.Join(root, "plugins") + for _, d := range []string{"plugins/Essentials/empty", "plugins/Essentials/data"} { + if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil { + t.Fatal(err) + } + } + for name, body := range map[string]string{ + "plugins/a.jar": "jar", + "plugins/Essentials/config.yml": "x: 1", + "plugins/Essentials/data/server.properties": "rcon.password=notthereal\n", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + for name, mode := range map[string]os.FileMode{"a.jar": 0o755, "Essentials/empty": 0o700} { + if err := os.Chmod(filepath.Join(plugins, name), mode); err != nil { + t.Fatal(err) + } + } + if err := os.Link(filepath.Join(root, "config/paper-global.yml"), filepath.Join(plugins, "stolen.yml")); err != nil { + t.Fatal(err) + } + if err := os.Link(filepath.Join(root, "server.properties"), filepath.Join(plugins, "props.txt")); err != nil { + t.Fatal(err) + } + symlink(t, "../config/paper-global.yml", filepath.Join(plugins, "sym.yml")) + + d, b := download(t, root, "plugins", true) + if d.Size != -1 || d.ContentType != DownloadZipType { + t.Fatalf("size %d, type %s", d.Size, d.ContentType) + } + want := map[string]string{ + "plugins/": "", + "plugins/Essentials/": "", + "plugins/Essentials/empty/": "", + "plugins/Essentials/data/": "", + "plugins/a.jar": "-rwxr-xr-x jar", + "plugins/Essentials/config.yml": "-rw-r--r-- x: 1", + // Only the world root's server.properties is the server's. + "plugins/Essentials/data/server.properties": "-rw-r--r-- rcon.password=notthereal\n", + "plugins/props.txt": "-rw-r--r-- " + redacted, + } + if got := unzipped(t, b); !reflect.DeepEqual(got, want) { + t.Fatalf("zip = %v\nwant %v", got, want) + } + if d.Skipped != 1 || d.Withheld != 1 { + t.Errorf("skipped %d, withheld %d; want 1 and 1", d.Skipped, d.Withheld) + } + if bytes.Contains(b, []byte("aVeryReal")) || bytes.Contains(b, []byte("hunter2")) { + t.Fatal("a secret is in the zip") + } + + // A nested folder unpacks as itself, not under its parents. + _, b = download(t, root, "plugins/Essentials/data", true) + want = map[string]string{ + "data/": "", + "data/server.properties": "-rw-r--r-- rcon.password=notthereal\n", + } + if got := unzipped(t, b); !reflect.DeepEqual(got, want) { + t.Fatalf("nested zip = %v\nwant %v", got, want) + } + }) + + // Redaction reads the file whole; one too big for that is refused, or left + // out of a folder, never sent as it is. + t.Run("a server.properties too big to redact is refused", func(t *testing.T) { + root := downloadWorld(t) + big := append([]byte("rcon.password=hunter2\n"), bytes.Repeat([]byte("#"), MaxReadBytes)...) + if err := os.WriteFile(filepath.Join(root, "server.properties"), big, 0o644); err != nil { + t.Fatal(err) + } + d, err := OpenDownload(root, "server.properties", false) + if err == nil { + d.Close() + t.Fatal("an oversized server.properties opened for download") + } + if !strings.Contains(err.Error(), "cannot be redacted") { + t.Fatalf("err = %v", err) + } + + if err := os.Link(filepath.Join(root, "server.properties"), filepath.Join(root, "config/props.txt")); err != nil { + t.Fatal(err) + } + d, b := download(t, root, "config", true) + want := map[string]string{"config/": "", "config/paper.yml": "-rw-r--r-- verbose: false\n"} + if got := unzipped(t, b); !reflect.DeepEqual(got, want) || d.Withheld != 2 { + t.Fatalf("zip = %v, withheld %d; want %v and 2", got, d.Withheld, want) + } + }) + + t.Run("a file that shrinks mid-download fails it", func(t *testing.T) { + root := downloadWorld(t) + d, err := OpenDownload(root, "config/paper.yml", false) + if err != nil { + t.Fatal(err) + } + defer d.Close() + if err := os.Truncate(filepath.Join(root, "config/paper.yml"), 3); err != nil { + t.Fatal(err) + } + if err := d.WriteTo(context.Background(), io.Discard); err == nil || !strings.Contains(err.Error(), "shrank") { + t.Fatalf("err = %v, want the shrank error", err) + } + }) + + t.Run("a cancelled download stops", func(t *testing.T) { + root := downloadWorld(t) + // Folders only: no file copy is there to notice the cancel. + if err := os.MkdirAll(filepath.Join(root, "empty/a/b"), 0o755); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + for _, c := range []struct { + name string + dir bool + }{{"config/paper.yml", false}, {"config", true}, {"empty", true}} { + d, err := OpenDownload(root, c.name, c.dir) + if err != nil { + t.Fatal(err) + } + if err := d.WriteTo(ctx, io.Discard); err != context.Canceled { + t.Errorf("%s: err = %v, want context.Canceled", c.name, err) + } + d.Close() + } + }) +} diff --git a/internal/fileedit/editor.go b/internal/fileedit/editor.go index 59407f8..898bebc 100644 --- a/internal/fileedit/editor.go +++ b/internal/fileedit/editor.go @@ -79,13 +79,12 @@ var ( ErrExists = errors.New("fileedit: the target already exists") ) -// Runner is the cluster-side half of one file operation: render and create the -// Job, wait for its Pod to reach a terminal phase, and return the marked JSON -// payload the Pod printed. It is one method rather than a create/poll/read trio -// because felis-api cannot poll a Job at all (no jobs:get — see FilesJobName), so -// there is no intermediate state a caller could usefully observe; the operation is -// synchronous from the API's point of view whether or not the seam pretends -// otherwise. +// Runner is the cluster-side half of a file operation. Run renders and creates +// the Job, waits for its Pod to reach a terminal phase, and returns the marked +// JSON payload the Pod printed: from the API's point of view an operation is one +// call. An upload too big for one request and an unzip can outlast any request, +// so those two are started instead (Start) and read back later (Ops), from the +// Jobs felis-api lists and the progress lines their Pods print. // // It is an interface so the Editor's orchestration and error mapping are tested // against a fake; the client-go implementation (K8sRunner) is integration-only. @@ -93,6 +92,11 @@ type Runner interface { // Run creates the Job for p and returns the raw JSON payload from the // ResultPrefix line of its Pod's log. Run(ctx context.Context, p JobParams) ([]byte, error) + // Start creates the Job for p and returns once it exists. + Start(ctx context.Context, p JobParams) error + // Ops reports the background operations (JobParams.Async) of one server + // whose Jobs the cluster still holds, newest first. + Ops(ctx context.Context, namespace, server string) ([]OpState, error) } // Config parameterises the file editor. Image has no default on purpose: it is @@ -139,6 +143,15 @@ type Config struct { // it must stay comfortably longer than the moment felis-api needs to read the // Pod's log, because the TTL takes the Pod (and its log) with the Job. TTLAfterFinished time.Duration + + // AsyncDeadline, AsyncTTL and AsyncCPULimit stand in for Deadline, + // TTLAfterFinished and CPULimit on an upload or unzip felis-api starts and + // does not wait on. Such a Job moves a whole archive or a file of gigabytes, + // so it gets hours; it stays after finishing long enough for the panel to + // show how it ended; and it gets a whole core, since inflating is CPU-bound. + AsyncDeadline time.Duration + AsyncTTL time.Duration + AsyncCPULimit string } // defaults applied when a Config field is left zero. They are sized for what a @@ -153,6 +166,9 @@ const ( defaultCPULimit = "500m" defaultMemLimit = "256Mi" defaultTTL = 2 * time.Minute + defaultAsyncDeadline = 2 * time.Hour + defaultAsyncTTL = 30 * time.Minute + defaultAsyncCPULimit = "1" ) // withDefaults returns a copy of c with zero fields filled, so a partially @@ -182,6 +198,15 @@ func (c Config) withDefaults() Config { if c.TTLAfterFinished <= 0 { c.TTLAfterFinished = defaultTTL } + if c.AsyncDeadline <= 0 { + c.AsyncDeadline = defaultAsyncDeadline + } + if c.AsyncTTL <= 0 { + c.AsyncTTL = defaultAsyncTTL + } + if c.AsyncCPULimit == "" { + c.AsyncCPULimit = defaultAsyncCPULimit + } return c } @@ -191,19 +216,29 @@ type Editor struct { Config Config } +// Listing is one directory as List returns it. +type Listing struct { + Entries []Entry + // Truncated reports that the directory holds more than MaxEntries. + Truncated bool + // Free is the bytes free on the server's volume, 0 when the Job could not + // tell. + Free int64 +} + // List returns one directory's entries, resolved under the server's world root. // An empty path lists the world root itself. -func (e *Editor) List(ctx context.Context, server, path string) ([]Entry, bool, error) { +func (e *Editor) List(ctx context.Context, server, path string) (Listing, error) { res, err := e.run(ctx, server, JobParams{Op: OpList, Path: path}) if err != nil { - return nil, false, err + return Listing{}, err } // A genuinely empty directory unmarshals Entries as nil; normalise it so the // handler serialises [] rather than null. if res.Entries == nil { res.Entries = []Entry{} } - return res.Entries, res.Truncated, nil + return Listing{Entries: res.Entries, Truncated: res.Truncated, Free: res.Avail}, nil } // Read returns a file's bytes, resolved under the server's world root, and the @@ -289,7 +324,7 @@ func (e *Editor) run(ctx context.Context, server string, p JobParams) (Result, e } cfg := e.Config.withDefaults() - opID, err := newOpID() + p, err := cfg.params(server, p) if err != nil { return Result{}, err } @@ -301,20 +336,6 @@ func (e *Editor) run(ctx context.Context, server string, p JobParams) (Result, e ctx, cancel := context.WithTimeout(ctx, cfg.Timeout) defer cancel() - p.Server = server - p.OpID = opID - p.WorldPVC = naming.WorldPVCName(server) - p.Namespace = cfg.Namespace - p.ServiceAccount = cfg.ServiceAccount - p.Image = cfg.Image - p.WorldsRoot = cfg.WorldsRoot - p.Deadline = cfg.Deadline - p.CPULimit = cfg.CPULimit - p.MemLimit = cfg.MemLimit - p.RunAsUser = cfg.RunAsUser - p.RunAsGroup = cfg.RunAsGroup - p.FSGroup = cfg.FSGroup - p.TTLAfterFinished = cfg.TTLAfterFinished payload, err := e.Runner.Run(ctx, p) if err != nil { return Result{}, err @@ -327,6 +348,94 @@ func (e *Editor) run(ctx context.Context, server string, p JobParams) (Result, e return res, resultError(res) } +// params fills in what every Job of server takes from the Config, and a fresh op +// id; p carries the op and its own fields. +func (c Config) params(server string, p JobParams) (JobParams, error) { + opID, err := newOpID() + if err != nil { + return JobParams{}, err + } + p.Server = server + p.OpID = opID + p.WorldPVC = naming.WorldPVCName(server) + p.Namespace = c.Namespace + p.ServiceAccount = c.ServiceAccount + p.Image = c.Image + p.WorldsRoot = c.WorldsRoot + p.Deadline = c.Deadline + p.CPULimit = c.CPULimit + p.MemLimit = c.MemLimit + p.RunAsUser = c.RunAsUser + p.RunAsGroup = c.RunAsGroup + p.FSGroup = c.FSGroup + p.TTLAfterFinished = c.TTLAfterFinished + return p, nil +} + +// The states of an OpState. +const ( + OpRunning = "running" + OpSucceeded = "succeeded" + OpFailed = "failed" +) + +// OpState is where one background file operation stands. +type OpState struct { + ID string + Op string + Path string + State string + // Started is when the Job was created; Finished when it ended, zero while it + // runs. + Started time.Time + Finished time.Time + // Done and Total are the bytes of the latest progress line, zero before the + // first. + Done, Total int64 + // Result is what the Job printed once it finished. It is nil while the Job + // runs, and for a Job that ended without printing one (killed at its + // deadline, out of memory, its bytes unfetchable), whose Reason says why. + Result *Result + Reason string +} + +// StartUpload starts landing the staged bytes src describes at path and returns +// without waiting, for a file too big to land inside one request (Upload). The +// Job checks what Upload's does; Ops reports how it ends. +func (e *Editor) StartUpload(ctx context.Context, server, path string, src UploadSource, overwrite bool) (OpState, error) { + return e.start(ctx, server, JobParams{ + Op: OpUpload, Path: path, Overwrite: overwrite, + SourceURL: src.URL, UploadToken: src.Token, UploadSize: src.Size, UploadSHA256: src.SHA256, + }) +} + +// StartUnzip starts extracting the .zip at path into the folder holding it and +// returns without waiting. Without overwrite an archive that would replace a +// file changes nothing and ends with CodeExists and the list (Result.Conflicts). +func (e *Editor) StartUnzip(ctx context.Context, server, path string, overwrite bool) (OpState, error) { + return e.start(ctx, server, JobParams{Op: OpUnzip, Path: path, Overwrite: overwrite}) +} + +// Ops reports the server's background operations the cluster still holds: the +// one running, if any, and those finished within AsyncTTL. +func (e *Editor) Ops(ctx context.Context, server string) ([]OpState, error) { + return e.Runner.Ops(ctx, e.Config.withDefaults().Namespace, server) +} + +func (e *Editor) start(ctx context.Context, server string, p JobParams) (OpState, error) { + cfg := e.Config.withDefaults() + p, err := cfg.params(server, p) + if err != nil { + return OpState{}, err + } + p.Async = true + p.Deadline, p.TTLAfterFinished, p.CPULimit = cfg.AsyncDeadline, cfg.AsyncTTL, cfg.AsyncCPULimit + if err := e.Runner.Start(ctx, p); err != nil { + return OpState{}, err + } + return OpState{ID: p.OpID, Op: p.Op, Path: p.Path, State: OpRunning, Started: time.Now()}, nil +} + // resultError translates a Result's code into the sentinel the API maps. An // unrecognised code is deliberately NOT swallowed as success: a Job reporting a // failure this build does not know about must still fail the request, or a future diff --git a/internal/fileedit/editor_test.go b/internal/fileedit/editor_test.go index 0a530b9..1df4e28 100644 --- a/internal/fileedit/editor_test.go +++ b/internal/fileedit/editor_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "testing" + "time" ) // fakeRunner stands in for the cluster: it records the JobParams the Editor @@ -14,6 +15,21 @@ type fakeRunner struct { got []JobParams payload []byte err error + + started []JobParams + startErr error + ops []OpState + opsArgs [][2]string +} + +func (f *fakeRunner) Start(_ context.Context, p JobParams) error { + f.started = append(f.started, p) + return f.startErr +} + +func (f *fakeRunner) Ops(_ context.Context, namespace, server string) ([]OpState, error) { + f.opsArgs = append(f.opsArgs, [2]string{namespace, server}) + return f.ops, f.err } func (f *fakeRunner) Run(_ context.Context, p JobParams) ([]byte, error) { @@ -37,15 +53,15 @@ func mustPayload(t *testing.T, res Result) []byte { // the editor pointed at the same volume the operator created and the reaper deletes. func TestEditorRendersParams(t *testing.T) { t.Run("list", func(t *testing.T) { - r := &fakeRunner{payload: mustPayload(t, Result{Entries: []Entry{{Name: "a"}}})} + r := &fakeRunner{payload: mustPayload(t, Result{Entries: []Entry{{Name: "a"}}, Avail: 7 << 30})} e := &Editor{Runner: r, Config: Config{Image: "img"}} - entries, truncated, err := e.List(context.Background(), "survival", "config") + ls, err := e.List(context.Background(), "survival", "config") if err != nil { t.Fatalf("List: %v", err) } - if len(entries) != 1 || truncated { - t.Fatalf("entries=%+v truncated=%v", entries, truncated) + if len(ls.Entries) != 1 || ls.Truncated || ls.Free != 7<<30 { + t.Fatalf("listing = %+v", ls) } p := r.got[0] if p.Op != OpList || p.Path != "config" || p.Server != "survival" { @@ -242,11 +258,11 @@ func TestEditorNormalisesEmptyResults(t *testing.T) { r := &fakeRunner{payload: mustPayload(t, Result{})} e := &Editor{Runner: r, Config: Config{Image: "img"}} - entries, _, err := e.List(context.Background(), "survival", "empty") + ls, err := e.List(context.Background(), "survival", "empty") if err != nil { t.Fatalf("List: %v", err) } - if entries == nil { + if ls.Entries == nil { t.Fatal("an empty directory must list as [], not nil") } @@ -297,3 +313,85 @@ func TestExtractResult(t *testing.T) { } }) } + +// TestEditorStartsBackgroundOps checks an upload or unzip too long to wait on is +// started, not run: its Job carries the async label and the longer deadline, +// the longer TTL Ops reads it back within, and the larger CPU share, and what +// comes back names the Job Ops will report on. +func TestEditorStartsBackgroundOps(t *testing.T) { + src := UploadSource{URL: "http://api/big", Token: "tok", Size: 5 << 30, SHA256: "sum"} + + t.Run("upload", func(t *testing.T) { + r := &fakeRunner{} + e := &Editor{Runner: r, Config: Config{Image: "img", Namespace: "mc"}} + before := time.Now() + st, err := e.StartUpload(context.Background(), "survival", "maps/world.zip", src, true) + if err != nil { + t.Fatalf("StartUpload: %v", err) + } + if r.calls != 0 || len(r.started) != 1 { + t.Fatalf("ran %d, started %d; want the one Job started and none waited on", r.calls, len(r.started)) + } + p := r.started[0] + if !p.Async || p.Op != OpUpload || p.Path != "maps/world.zip" || !p.Overwrite || + p.SourceURL != src.URL || p.UploadToken != "tok" || p.UploadSize != 5<<30 || p.UploadSHA256 != "sum" { + t.Fatalf("params = %+v", p) + } + if p.Deadline != 2*time.Hour || p.TTLAfterFinished != 30*time.Minute || p.CPULimit != "1" || p.MemLimit != "256Mi" { + t.Fatalf("deadline %v ttl %v cpu %q mem %q, want 2h 30m 1 256Mi", + p.Deadline, p.TTLAfterFinished, p.CPULimit, p.MemLimit) + } + if p.Namespace != "mc" || p.Server != "survival" || p.WorldPVC != "world-survival-0" || p.Image != "img" || p.OpID == "" { + t.Fatalf("params = %+v", p) + } + if st.ID != p.OpID || st.Op != OpUpload || st.Path != "maps/world.zip" || st.State != OpRunning || st.Started.Before(before) { + t.Fatalf("state = %+v, want the started Job %s running", st, p.OpID) + } + + if _, err := e.StartUpload(context.Background(), "survival", "maps/world.zip", src, false); err != nil || r.started[1].Overwrite { + t.Fatalf("an upload that must not replace a file started with %+v (%v)", r.started[1], err) + } + }) + + t.Run("unzip, with its own limits", func(t *testing.T) { + r := &fakeRunner{} + e := &Editor{Runner: r, Config: Config{Image: "img", AsyncDeadline: time.Hour, AsyncTTL: time.Minute, AsyncCPULimit: "2"}} + st, err := e.StartUnzip(context.Background(), "survival", "maps/world.zip", false) + if err != nil { + t.Fatalf("StartUnzip: %v", err) + } + p := r.started[0] + if !p.Async || p.Op != OpUnzip || p.Path != "maps/world.zip" || p.Overwrite || p.SourceURL != "" { + t.Fatalf("params = %+v", p) + } + if p.Deadline != time.Hour || p.TTLAfterFinished != time.Minute || p.CPULimit != "2" { + t.Fatalf("deadline %v ttl %v cpu %q, want the configured 1h 1m 2", p.Deadline, p.TTLAfterFinished, p.CPULimit) + } + if st.ID != p.OpID || st.Op != OpUnzip { + t.Fatalf("state = %+v", st) + } + }) + + t.Run("a Job that could not be created", func(t *testing.T) { + boom := errors.New("forbidden") + r := &fakeRunner{startErr: boom} + e := &Editor{Runner: r, Config: Config{Image: "img"}} + st, err := e.StartUnzip(context.Background(), "survival", "a.zip", false) + if !errors.Is(err, boom) || st != (OpState{}) { + t.Fatalf("state %+v err %v, want nothing started and %v", st, err, boom) + } + }) + + t.Run("ops", func(t *testing.T) { + want := []OpState{{ID: "0a", State: OpRunning}} + r := &fakeRunner{ops: want} + e := &Editor{Runner: r, Config: Config{Image: "img"}} + got, err := e.Ops(context.Background(), "survival") + if err != nil || len(got) != 1 || got[0] != want[0] { + t.Fatalf("Ops = %+v %v", got, err) + } + if r.opsArgs[0] != [2]string{"minecraft", "survival"} { + t.Fatalf("asked %v, want the default namespace and the server", r.opsArgs[0]) + } + }) +} diff --git a/internal/fileedit/exec.go b/internal/fileedit/exec.go index db8a250..8dc25f8 100644 --- a/internal/fileedit/exec.go +++ b/internal/fileedit/exec.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "io/fs" + "math" "os" "path" "strings" @@ -20,11 +21,12 @@ import ( ) // The operations the editor supports: list a directory, read a file, write a -// file, make a directory, delete, rename, and upload. The set is closed; there is -// no chmod, chown, link or copy. Every op resolves every path through os.Root (see -// Execute), and each mutating op carries its own containment note below. +// file, make a directory, delete, rename, upload, and extract a .zip. The set is +// closed; there is no chmod, chown, link or copy. Every op resolves every path +// through os.Root (see Execute), and each mutating op carries its own +// containment note below. // -// A write or upload DOES land arbitrary bytes at any path inside the mount, and +// A write, upload or unzip DOES land arbitrary bytes at any path inside the mount, and // that is a real capability rather than an oversight: the root is the server's // whole working directory (see Config.WorldsRoot), so an owner can upload // plugins/.jar and Paper will load it on the next boot. It is the same power a @@ -33,8 +35,8 @@ import ( // image curation. Images are admin-only (POST /images, POST /images/build) and // modpack submissions need an admin verdict, so this is the one owner-tier route // that lands executable code in a backend pod. That trade was made deliberately; -// if it is ever revisited, the guard belongs in land() below, which is the single -// choke point both byte-landing ops route through. +// if it is ever revisited, the guard belongs in land() below, the choke point +// write and upload route through, and in unzip's extractOne (unzip.go). const ( OpList = "list" OpRead = "read" @@ -43,6 +45,7 @@ const ( OpDelete = "delete" OpRename = "rename" OpUpload = "upload" + OpUnzip = "unzip" ) // mutates reports whether op changes the world, and so whether its Job gets the @@ -54,7 +57,7 @@ func mutates(op string) bool { return op != OpList && op != OpRead } // validOp reports whether op is one the Job knows. func validOp(op string) bool { switch op { - case OpList, OpRead, OpWrite, OpMkdir, OpDelete, OpRename, OpUpload: + case OpList, OpRead, OpWrite, OpMkdir, OpDelete, OpRename, OpUpload, OpUnzip: return true } return false @@ -133,12 +136,12 @@ const UploadTokenEnv = "FELIS_UPLOAD_TOKEN" // - MaxEntries bounds a listing. A world's region/ directory legitimately holds // thousands of .mca files, so this truncates rather than errors (Truncated // says so), keeping the log line bounded while still being useful. -// - MaxUploadBytes bounds an upload. Its bytes travel neither through the Job -// spec nor the pod log — felis-api stages them and the Job fetches them — so -// the bound is the request body instead: the Cloudflare edge refuses bodies -// over 100 MB on the Free and Pro plans, and 64 MiB covers the largest plugin -// jars (a Geyser build is about 20 MiB) with room to spare. A whole world is -// a different operation (a restore), not an upload. +// - MaxUploadBytes bounds an upload sent as ONE request body: the Cloudflare +// edge refuses bodies over 100 MB on the Free and Pro plans, and 64 MiB +// covers the largest plugin jars (a Geyser build is about 20 MiB) with room +// to spare. It is felis-api's bound on that route only. A bigger file arrives +// in parts and is bounded by nothing but the room on the server's volume, +// which the Job checks before it fetches a byte (upload). const ( MaxWriteBytes = 256 << 10 // 256 KiB MaxReadBytes = 1 << 20 // 1 MiB @@ -183,6 +186,22 @@ type Result struct { // conflict, the file as it is now. A client hands it back as the expected // hash of its next write (see write). SHA256 string `json:"sha256,omitempty"` + + // Conflicts lists, relative to the root and sorted, the existing files an + // unzip would replace: the first MaxConflicts of them. ConflictCount is how + // many there are in all. + Conflicts []string `json:"conflicts,omitempty"` + ConflictCount int `json:"conflict_count,omitempty"` + // Entry names what an unzip refused: the archive entry, or the path on the + // server it collides with. + Entry string `json:"entry,omitempty"` + // Need and Avail are, on a no_space an upload or unzip saw coming, the bytes + // it needs and the bytes the volume has free. A listing sets Avail too. + Need int64 `json:"need,omitempty"` + Avail int64 `json:"avail,omitempty"` + // Files and Bytes are what a successful unzip extracted. + Files int `json:"files,omitempty"` + Bytes int64 `json:"bytes,omitempty"` } // Request is one file operation. Op decides which of the other fields it reads. @@ -200,10 +219,15 @@ type Request struct { // panel's "new file", which must never truncate a file it did not know was // there. CreateOnly bool - // Upload is where an upload's bytes come from; Overwrite lets it replace a - // file already at the path. + // Upload is where an upload's bytes come from. Overwrite lets an upload + // replace a file already at the path, and an unzip replace the files it + // collides with. Upload *Upload Overwrite bool + // Progress, when set, hears how far an upload or unzip has got: bytes landed + // so far out of the total. It is called from the copy loop, often; the caller + // throttles. + Progress func(done, total int64) } // Upload describes the bytes an upload lands. Size and SHA256 are what felis-api @@ -262,7 +286,7 @@ func Execute(root string, req Request) (Result, error) { switch req.Op { case OpList: - return list(r, path), nil + return list(r, root, path), nil case OpRead: return read(r, path), nil case OpWrite: @@ -277,7 +301,9 @@ func Execute(root string, req Request) (Result, error) { if req.Upload == nil { return Result{}, errors.New("an upload needs a source") } - return upload(r, path, *req.Upload, req.Overwrite) + return upload(r, root, path, *req.Upload, req.Overwrite, req.Progress) + case OpUnzip: + return unzip(r, root, path, req.Overwrite, req.Progress), nil default: return Result{}, fmt.Errorf("unknown op %q", req.Op) } @@ -285,8 +311,10 @@ func Execute(root string, req Request) (Result, error) { // list reads one directory. It does not recurse: a browser asks for one level at // a time, and recursion would make both the result size and the traversal cost -// unbounded in a world directory. -func list(r *os.Root, path string) Result { +// unbounded in a world directory. It also reports the room left on the volume +// (Avail), so the panel can refuse an upload the volume cannot take before +// sending a byte of it. +func list(r *os.Root, rootPath, path string) Result { f, err := r.Open(path) if err != nil { return failure(err, path) @@ -318,7 +346,11 @@ func list(r *os.Root, path string) Result { } entries = append(entries, e) } - return Result{Entries: entries, Truncated: truncated} + res := Result{Entries: entries, Truncated: truncated} + if avail, _, err := statfs(rootPath); err == nil { + res.Avail = int64(min(avail, math.MaxInt64)) + } + return res } // secretConfigPath is the one file in a world mount holding PLATFORM secret @@ -370,6 +402,13 @@ func read(r *os.Root, name string) Result { if info.IsDir() { return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is a directory, not a file", name)} } + // The name check above answers the plain path with a clear reason; this one + // catches the same file reached through a link (see Guard). + withhold, redact := NewGuard(r).Rule(info) + if withhold { + return Result{Code: CodeBadPath, Error: fmt.Sprintf( + "%s is the file holding the proxy forwarding secret, which is shared cluster-wide, and is not readable through the editor", name)} + } if info.Size() > MaxReadBytes { return Result{Code: CodeTooLarge, Error: fmt.Sprintf( "%s is %d bytes; the editor reads at most %d", name, info.Size(), MaxReadBytes)} @@ -382,7 +421,11 @@ func read(r *os.Root, name string) Result { if err != nil { return failure(err, name) } - return Result{Content: redactSecretProps(name, b), SHA256: digest(b)} + content := b + if redact { + content = RedactProps(b) + } + return Result{Content: content, SHA256: digest(b)} } // propsPath is the server's main config file, and rconPasswordKey the one line in @@ -515,8 +558,8 @@ func (e *transferError) Error() string { return "fetch upload: " + e.err.Error() func (e *transferError) Unwrap() error { return e.err } // land atomically puts the bytes fill writes at target, the path landingTarget -// returned for name. It is the single choke point both byte-landing ops (write and -// upload) route through. +// returned for name. Write and upload both land through it; unzip lands a whole +// tree at once and has its own path (unzip.go). // // The bytes go to a temporary sibling that is synced and then renamed over the // target, so a full disk, a Job killed at its deadline or a crashed node leaves @@ -596,15 +639,19 @@ func syncDir(r *os.Root, dir string) { // The fetched bytes must match both the size and the SHA-256 felis-api received; // either mismatch is a broken transfer, and the target is left as it was. A // success has landed exactly what felis-api staged, whose digest it already holds. -func upload(r *os.Root, name string, u Upload, overwrite bool) (Result, error) { - if u.Size > MaxUploadBytes { - return Result{Code: CodeTooLarge, Error: fmt.Sprintf( - "the upload is %d bytes; the editor uploads at most %d", u.Size, MaxUploadBytes)}, nil - } +// +// There is no size ceiling here. The volume is the bound, and it is checked up +// front: the new bytes land beside the file they replace until the rename, so +// they need their whole size free whatever is already at the path. +func upload(r *os.Root, rootPath, name string, u Upload, overwrite bool, progress func(done, total int64)) (Result, error) { target, mode, res := landingTarget(r, name, overwrite) if res.Code != "" { return res, nil } + if avail, _, err := statfs(rootPath); err == nil && uint64(u.Size) > avail { + return Result{Code: CodeNoSpace, Need: u.Size, Avail: int64(min(avail, math.MaxInt64)), Error: fmt.Sprintf( + "%s is %d bytes and the server's volume has %d free; nothing was changed", name, u.Size, avail)}, nil + } return land(r, name, target, mode, func(w io.Writer) error { body, err := u.Open() if err != nil { @@ -612,8 +659,13 @@ func upload(r *os.Root, name string, u Upload, overwrite bool) (Result, error) { } defer body.Close() h := sha256.New() + var done int64 + out := io.MultiWriter(w, h) + if progress != nil { + out = countingWriter{out, func(n int) { done += int64(n); progress(done, u.Size) }} + } // One byte past Size so a source that sends more than it promised is seen. - n, err := io.Copy(io.MultiWriter(w, h), sourceReader{io.LimitReader(body, u.Size+1)}) + n, err := io.Copy(out, sourceReader{io.LimitReader(body, u.Size+1)}) if err != nil { return err } diff --git a/internal/fileedit/exec_test.go b/internal/fileedit/exec_test.go index a90dced..c1897a5 100644 --- a/internal/fileedit/exec_test.go +++ b/internal/fileedit/exec_test.go @@ -2,7 +2,11 @@ package fileedit import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" + "math" "os" "path/filepath" "strings" @@ -176,6 +180,31 @@ func TestExecuteHappyPath(t *testing.T) { } }) + t.Run("a listing reports the room left on the volume", func(t *testing.T) { + prev := statfs + t.Cleanup(func() { statfs = prev }) + var asked string + statfs = func(dir string) (uint64, uint64, error) { asked = dir; return 12345, 99999, nil } + res, err := run(root, OpList, "config", nil, "") + if err != nil || res.Code != "" { + t.Fatalf("Execute: %v %+v", err, res) + } + if res.Avail != 12345 || asked != root { + t.Fatalf("avail = %d measured at %q, want 12345 at %q", res.Avail, asked, root) + } + + statfs = func(string) (uint64, uint64, error) { return math.MaxUint64, math.MaxUint64, nil } + if res, _ := run(root, OpList, "config", nil, ""); res.Avail != math.MaxInt64 { + t.Fatalf("avail = %d, want it clamped to %d", res.Avail, int64(math.MaxInt64)) + } + + statfs = func(string) (uint64, uint64, error) { return 1, 1, errors.New("no statfs") } + res, err = run(root, OpList, "config", nil, "") + if err != nil || res.Code != "" || len(res.Entries) != 1 || res.Avail != 0 { + t.Fatalf("a volume that cannot be measured still lists, with no room reported: %v %+v", err, res) + } + }) + t.Run("read a file", func(t *testing.T) { res, err := run(root, OpRead, "server.properties", nil, "") if err != nil { @@ -396,6 +425,11 @@ func TestReadRedactsRconPassword(t *testing.T) { if err != nil { t.Fatalf("Execute: %v", err) } + // The hash is the file on disk, the one a save's expect_sha256 is checked + // against, not the redacted copy the editor shows. + if sum := sha256.Sum256([]byte(props)); res.SHA256 != hex.EncodeToString(sum[:]) { + t.Fatalf("sha256 = %s, want the hash of the file as stored", res.SHA256) + } got := string(res.Content) if strings.Contains(got, "hunter2") { t.Fatalf("read returned the RCON password (spec §286):\n%s", got) @@ -420,6 +454,51 @@ func TestReadRedactsRconPassword(t *testing.T) { } } +// TestReadGuardsLinksToGuardedFiles: a plugin runs as the game uid and can leave +// a link to either guarded file anywhere in the world. Read under the link's +// name, the forwarding secret is still refused and the RCON password still +// redacted, whether the link is symbolic, a hard link, or a linked folder. +func TestReadGuardsLinksToGuardedFiles(t *testing.T) { + root, _ := worldRoot(t) + const secret = "secret: aVeryRealForwardingKey" + if err := os.WriteFile(filepath.Join(root, "config", "paper-global.yml"), []byte(secret), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "server.properties"), []byte("motd=hi\nrcon.password=hunter2\n"), 0o644); err != nil { + t.Fatal(err) + } + symlink(t, "config/paper-global.yml", filepath.Join(root, "sym.yml")) + symlink(t, "config", filepath.Join(root, "cfg-link")) + symlink(t, "server.properties", filepath.Join(root, "sym.properties")) + for _, l := range [][2]string{ + {"config/paper-global.yml", "hard.yml"}, + {"server.properties", "hard.properties"}, + } { + if err := os.Link(filepath.Join(root, l[0]), filepath.Join(root, l[1])); err != nil { + t.Fatal(err) + } + } + + for _, name := range []string{"sym.yml", "cfg-link/paper-global.yml", "hard.yml"} { + res, err := run(root, OpRead, name, nil, "") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if res.Code != CodeBadPath || strings.Contains(string(res.Content), "aVeryReal") { + t.Errorf("%s: result = %+v, want bad_path and no secret", name, res) + } + } + for _, name := range []string{"sym.properties", "hard.properties"} { + res, err := run(root, OpRead, name, nil, "") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if want := "motd=hi\nrcon.password=" + redactedValue + "\n"; res.Code != "" || string(res.Content) != want { + t.Errorf("%s: result = %+v, want content %q", name, res, want) + } + } +} + // TestWriteIsAtomic is the durability contract: a write that fails part-way leaves // the original file byte-for-byte intact and no stray sibling behind, and a write // that succeeds keeps the file's mode. diff --git a/internal/fileedit/guard.go b/internal/fileedit/guard.go new file mode 100644 index 0000000..38aff8f --- /dev/null +++ b/internal/fileedit/guard.go @@ -0,0 +1,58 @@ +package fileedit + +import ( + "io/fs" + "os" + "path" +) + +// What leaves a world mount — a read, a download, a world export, a backup +// export — passes the same two guards: the forwarding-secret file +// (secretConfigPath) is withheld, and server.properties has its RCON password +// redacted (propsPath). +// +// On a live mount both are matched by the file itself (os.SameFile), not by the +// name it was reached under. A plugin runs arbitrary code as the game uid and can +// leave a symbolic or hard link to either file anywhere in the world; a name +// check alone would hand the secret out under the link's name. A stored archive +// has only names, so ArchiveRule matches those. + +// Guard knows the two guarded files of one world mount. +type Guard struct { + secret, props fs.FileInfo +} + +// NewGuard looks the guarded files up in r. One that is missing guards nothing: +// no file can be the same file as it. +func NewGuard(r *os.Root) Guard { + var g Guard + if fi, err := r.Stat(secretConfigPath); err == nil { + g.secret = fi + } + if fi, err := r.Stat(propsPath); err == nil { + g.props = fi + } + return g +} + +// Rule reports whether the file fi describes must be withheld, or sent only +// through RedactProps. +func (g Guard) Rule(fi fs.FileInfo) (withhold, redact bool) { + if g.secret != nil && os.SameFile(g.secret, fi) { + return true, false + } + return false, g.props != nil && os.SameFile(g.props, fi) +} + +// ArchiveRule is Rule for an entry of a stored world archive, by its name +// cleaned as a path, so "./server.properties" is server.properties too. +func ArchiveRule(name string) (withhold, redact bool) { + name = path.Clean(name) + return name == secretConfigPath, name == propsPath +} + +// RedactProps replaces the RCON password in server.properties content with +// redactedValue (see redactSecretProps for why a placeholder and not a blank). +func RedactProps(content []byte) []byte { + return redactSecretProps(propsPath, content) +} diff --git a/internal/fileedit/guard_test.go b/internal/fileedit/guard_test.go new file mode 100644 index 0000000..d5b2970 --- /dev/null +++ b/internal/fileedit/guard_test.go @@ -0,0 +1,25 @@ +package fileedit + +import "testing" + +// TestArchiveRule: a stored archive's entry is matched by its name as a path, +// however the archive spelled it. +func TestArchiveRule(t *testing.T) { + for _, c := range []struct { + name string + withhold, redact bool + }{ + {"config/paper-global.yml", true, false}, + {"./config/paper-global.yml", true, false}, + {"config//paper-global.yml", true, false}, + {"server.properties", false, true}, + {"./server.properties", false, true}, + {"plugins/server.properties", false, false}, + {"plugins/config/paper-global.yml", false, false}, + {"config/paper.yml", false, false}, + } { + if w, r := ArchiveRule(c.name); w != c.withhold || r != c.redact { + t.Errorf("ArchiveRule(%q) = %v, %v; want %v, %v", c.name, w, r, c.withhold, c.redact) + } + } +} diff --git a/internal/fileedit/jobspec.go b/internal/fileedit/jobspec.go index 0c30426..3088163 100644 --- a/internal/fileedit/jobspec.go +++ b/internal/fileedit/jobspec.go @@ -25,6 +25,11 @@ const ( LabelServer = "felis.lolicon.best/server" LabelOpID = "felis.lolicon.best/files-op" LabelMode = "felis.lolicon.best/files-mode" + // LabelAsync marks the Job of an upload or unzip felis-api started and does + // not wait on (Editor.StartUpload, Editor.StartUnzip); Ops finds them by it. + // AnnotationPath names the file such a Job works on, for Ops to show. + LabelAsync = "felis.lolicon.best/files-async" + AnnotationPath = "felis.lolicon.best/files-path" managedByValue = "felis-files" componentValue = "world-files" @@ -59,7 +64,11 @@ type JobParams struct { UploadSize int64 UploadSHA256 string Overwrite bool - WorldPVC string + // Async marks a Job felis-api does not wait on: it carries LabelAsync and + // AnnotationPath, which Ops reads it back by. Only an upload or an unzip + // runs so. + Async bool + WorldPVC string Namespace string ServiceAccount string @@ -92,13 +101,17 @@ type JobParams struct { func FilesJobName(server, opID string) string { return "files-" + server + "-" + opID } func filesLabels(p JobParams) map[string]string { - return map[string]string{ + l := map[string]string{ LabelManagedBy: managedByValue, LabelComponent: componentValue, LabelServer: p.Server, LabelOpID: p.OpID, LabelMode: p.Op, } + if p.Async { + l[LabelAsync] = "true" + } + return l } // FilesJob renders the file-editor Job. Its isolation is the strictest of the three @@ -152,6 +165,9 @@ func FilesJob(p JobParams) (*batchv1.Job, error) { if p.Op == OpUpload && (p.SourceURL == "" || p.UploadToken == "") { return nil, fmt.Errorf("fileedit: an upload needs a source URL and a token") } + if p.Async && p.Op != OpUpload && p.Op != OpUnzip { + return nil, fmt.Errorf("fileedit: only an upload or an unzip runs in the background, not %s", p.Op) + } limits, err := resourceLimits(p.CPULimit, p.MemLimit) if err != nil { return nil, err @@ -195,6 +211,10 @@ func FilesJob(p JobParams) (*batchv1.Job, error) { if p.Overwrite { args = append(args, "--overwrite") } + case OpUnzip: + if p.Overwrite { + args = append(args, "--overwrite") + } } container := corev1.Container{ Name: containerName, @@ -235,9 +255,10 @@ func FilesJob(p JobParams) (*batchv1.Job, error) { job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ - Name: FilesJobName(p.Server, p.OpID), - Namespace: p.Namespace, - Labels: filesLabels(p), + Name: FilesJobName(p.Server, p.OpID), + Namespace: p.Namespace, + Labels: filesLabels(p), + Annotations: filesAnnotations(p), }, Spec: batchv1.JobSpec{ // One shot: a file operation that failed must surface its failure, not be @@ -297,12 +318,12 @@ func int64Ptr(i int64) *int64 { return &i } // filesCapabilities is what the root executor keeps after dropping ALL (see // Config.RunAsUser). DAC_OVERRIDE opens a mode-0600 file (level.dat) the game wrote -// as its own uid, which a fixed non-root uid could not. A write, mkdir or upload -// also keeps CHOWN so what it creates can be handed to naming.GameUID (exec.go -// ownWritten). List, read, delete and rename create nothing and get no more than -// they need. +// as its own uid, which a fixed non-root uid could not. A write, mkdir, upload or +// unzip also keeps CHOWN so what it creates can be handed to naming.GameUID +// (exec.go ownWritten). List, read, delete and rename create nothing and get no +// more than they need. func filesCapabilities(op string) []corev1.Capability { - if op == OpWrite || op == OpMkdir || op == OpUpload { + if op == OpWrite || op == OpMkdir || op == OpUpload || op == OpUnzip { return []corev1.Capability{"CHOWN", "DAC_OVERRIDE"} } return []corev1.Capability{"DAC_OVERRIDE"} @@ -323,3 +344,12 @@ func filesPodSecurityContext(p JobParams) *corev1.PodSecurityContext { } return sc } + +// filesAnnotations names the file an async Job works on. A path does not fit a +// label (63 characters, no slashes), so it rides an annotation. +func filesAnnotations(p JobParams) map[string]string { + if !p.Async { + return nil + } + return map[string]string{AnnotationPath: p.Path} +} diff --git a/internal/fileedit/jobspec_test.go b/internal/fileedit/jobspec_test.go index 49fd773..5f97cff 100644 --- a/internal/fileedit/jobspec_test.go +++ b/internal/fileedit/jobspec_test.go @@ -128,7 +128,7 @@ func TestFilesJobIsolation(t *testing.T) { chown bool }{ {OpList, false}, {OpRead, false}, {OpDelete, false}, {OpRename, false}, - {OpWrite, true}, {OpMkdir, true}, {OpUpload, true}, + {OpWrite, true}, {OpMkdir, true}, {OpUpload, true}, {OpUnzip, true}, } { j, err := FilesJob(opParams(tc.op)) if err != nil { @@ -216,6 +216,7 @@ func TestFilesJobWorldMountIsReadOnlyForReads(t *testing.T) { {OpDelete, false}, {OpRename, false}, {OpUpload, false}, + {OpUnzip, false}, } for _, tc := range cases { t.Run(tc.op, func(t *testing.T) { @@ -435,6 +436,9 @@ func TestFilesJobRejectsBadParams(t *testing.T) { p.Op, p.Content = OpWrite, make([]byte, MaxWriteBytes+1) }}, {"bad cpu limit", func(p *JobParams) { p.CPULimit = "half" }}, + {"a read in the background", func(p *JobParams) { p.Async = true }}, + {"a write in the background", func(p *JobParams) { p.Op, p.Async = OpWrite, true }}, + {"a delete in the background", func(p *JobParams) { p.Op, p.Async = OpDelete, true }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -446,3 +450,36 @@ func TestFilesJobRejectsBadParams(t *testing.T) { }) } } + +// TestFilesJobAsync checks a background Job is marked so Ops finds it, on the +// Job and on its Pod, and carries the path Ops shows; a Job felis-api waits on +// carries neither, so Ops never reports it. +func TestFilesJobAsync(t *testing.T) { + for _, op := range []string{OpUpload, OpUnzip} { + t.Run(op, func(t *testing.T) { + p := opParams(op) + p.Path, p.Async = "maps/world.zip", true + j, err := FilesJob(p) + if err != nil { + t.Fatalf("FilesJob: %v", err) + } + if j.Labels[LabelAsync] != "true" || j.Spec.Template.Labels[LabelAsync] != "true" { + t.Fatalf("job labels %v, pod labels %v, want %s=true on both", j.Labels, j.Spec.Template.Labels, LabelAsync) + } + if len(j.Annotations) != 1 || j.Annotations[AnnotationPath] != "maps/world.zip" { + t.Fatalf("annotations = %v, want only %s", j.Annotations, AnnotationPath) + } + + p.Async = false + j, err = FilesJob(p) + if err != nil { + t.Fatalf("FilesJob: %v", err) + } + _, onJob := j.Labels[LabelAsync] + _, onPod := j.Spec.Template.Labels[LabelAsync] + if onJob || onPod || j.Annotations != nil { + t.Fatalf("a Job waited on is labelled %v / %v and annotated %v", j.Labels, j.Spec.Template.Labels, j.Annotations) + } + }) + } +} diff --git a/internal/fileedit/k8sjobs.go b/internal/fileedit/k8sjobs.go index 8f68039..818b0d0 100644 --- a/internal/fileedit/k8sjobs.go +++ b/internal/fileedit/k8sjobs.go @@ -3,12 +3,15 @@ package fileedit import ( "bufio" "context" + "encoding/json" "errors" "fmt" "io" + "sort" "strings" "time" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -185,3 +188,133 @@ func tail(log string) string { } return "..." + log[len(log)-n:] } + +// Start creates the Job for p and returns without waiting on it. Ops reads it +// back. +func (k *K8sRunner) Start(ctx context.Context, p JobParams) error { + job, err := FilesJob(p) + if err != nil { + return err + } + if _, err := k.cs.BatchV1().Jobs(p.Namespace).Create(ctx, job, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("fileedit: create file job: %w", err) + } + return nil +} + +// maxOps bounds what Ops reports, and so how many Pod logs one call reads. Only +// one background op runs per server at a time (it holds the world volume), so +// this many are the one running and the latest that finished within AsyncTTL. +const maxOps = 10 + +// opLogLines is how much of an op's log Ops reads: the result line is the last +// thing the Job prints to stdout, the progress lines come before it, and a +// failed run ends with one line on stderr. +const opLogLines = 20 + +// Ops lists the server's background Jobs, newest first, with how far each has +// got and how it ended, read from the tail of its Pod's log. A Pod whose log +// cannot be read yet (still pulling its image) or any more (its node went away) +// reports no progress rather than failing the listing. +func (k *K8sRunner) Ops(ctx context.Context, namespace, server string) ([]OpState, error) { + sel := metav1.ListOptions{LabelSelector: LabelManagedBy + "=" + managedByValue + "," + + LabelServer + "=" + server + "," + LabelAsync + "=true"} + jobs, err := k.cs.BatchV1().Jobs(namespace).List(ctx, sel) + if err != nil { + return nil, fmt.Errorf("fileedit: list file jobs: %w", err) + } + pods, err := k.cs.CoreV1().Pods(namespace).List(ctx, sel) + if err != nil { + return nil, fmt.Errorf("fileedit: list file job pods: %w", err) + } + // backoffLimit is 0, so a Job has one Pod; the newest wins all the same. + podOf := map[string]*corev1.Pod{} + for i := range pods.Items { + pod := &pods.Items[i] + id := pod.Labels[LabelOpID] + if cur := podOf[id]; cur == nil || pod.CreationTimestamp.After(cur.CreationTimestamp.Time) { + podOf[id] = pod + } + } + items := jobs.Items + sort.SliceStable(items, func(i, j int) bool { + return items[i].CreationTimestamp.After(items[j].CreationTimestamp.Time) + }) + if len(items) > maxOps { + items = items[:maxOps] + } + out := make([]OpState, 0, len(items)) + for i := range items { + log := "" + if pod := podOf[items[i].Labels[LabelOpID]]; pod != nil && pod.Status.Phase != corev1.PodPending { + log, _ = k.logTail(ctx, namespace, pod.Name) + } + out = append(out, opState(&items[i], log)) + } + return out, nil +} + +// logTail reads the last opLogLines lines of a Pod's log. +func (k *K8sRunner) logTail(ctx context.Context, namespace, pod string) (string, error) { + lines := int64(opLogLines) + stream, err := k.cs.CoreV1().Pods(namespace).GetLogs(pod, &corev1.PodLogOptions{ + Container: containerName, TailLines: &lines, + }).Stream(ctx) + if err != nil { + return "", err + } + defer stream.Close() + b, err := io.ReadAll(io.LimitReader(stream, maxLogBytes)) + return string(b), err +} + +// opState reads one background Job, and the tail of its Pod's log, as an +// OpState. The printed result decides the outcome whatever the Job's condition +// says: a Job killed at its deadline just after printing did finish its work. +// A Job that ended without one failed, and the condition's reason says how +// (DeadlineExceeded, BackoffLimitExceeded); a Job that completed but whose log +// could not be read has an outcome no one can tell, ResultUnavailable. +func opState(job *batchv1.Job, log string) OpState { + st := OpState{ + ID: job.Labels[LabelOpID], Op: job.Labels[LabelMode], Path: job.Annotations[AnnotationPath], + State: OpRunning, Started: job.CreationTimestamp.Time, + } + if p, ok := lastProgress(log); ok { + st.Done, st.Total = p.Done, p.Total + } + ended, reason := false, "" + for _, c := range job.Status.Conditions { + if c.Status != corev1.ConditionTrue { + continue + } + switch c.Type { + case batchv1.JobComplete, batchv1.JobSuccessCriteriaMet: + ended, reason = true, "ResultUnavailable" + st.Finished = c.LastTransitionTime.Time + case batchv1.JobFailed, batchv1.JobFailureTarget: + ended, reason = true, c.Reason + st.Finished = c.LastTransitionTime.Time + } + } + if !ended { + return st + } + if payload, ok := extractResult(log); ok { + var res Result + if json.Unmarshal(payload, &res) == nil { + st.Result = &res + } + } + switch { + case st.Result != nil && st.Result.Code == "": + st.State = OpSucceeded + case st.Result != nil: + st.State = OpFailed + default: + st.State, st.Reason = OpFailed, reason + if st.Reason == "" { + st.Reason = "Failed" + } + } + return st +} diff --git a/internal/fileedit/k8sjobs_test.go b/internal/fileedit/k8sjobs_test.go new file mode 100644 index 0000000..12098bb --- /dev/null +++ b/internal/fileedit/k8sjobs_test.go @@ -0,0 +1,241 @@ +package fileedit + +import ( + "context" + "fmt" + "testing" + "time" + + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +var ( + opCreated = time.Date(2026, 9, 28, 10, 0, 0, 0, time.UTC) + opEnded = opCreated.Add(3 * time.Minute) +) + +func asyncJob(conds ...batchv1.JobCondition) *batchv1.Job { + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "files-survival-0a", + CreationTimestamp: metav1.NewTime(opCreated), + Labels: map[string]string{LabelOpID: "0a", LabelMode: OpUnzip}, + Annotations: map[string]string{AnnotationPath: "maps/world.zip"}, + }, + Status: batchv1.JobStatus{Conditions: conds}, + } +} + +func cond(typ batchv1.JobConditionType, status corev1.ConditionStatus, reason string) batchv1.JobCondition { + return batchv1.JobCondition{Type: typ, Status: status, Reason: reason, LastTransitionTime: metav1.NewTime(opEnded)} +} + +// TestOpState pins how a background Job and the tail of its log read as an +// OpState: the printed result decides the outcome whatever the Job's condition +// says, and a Job that ended without one failed for the condition's reason. +func TestOpState(t *testing.T) { + progress := ProgressPrefix + `{"done":10,"total":100}` + "\n" + + "a stderr line\n" + + ProgressPrefix + `{"done":40,"total":100}` + "\n" + ok := ResultPrefix + `{"files":3,"bytes":40}` + "\n" + conflict := ResultPrefix + `{"code":"exists","conflicts":["a.txt"],"conflict_count":1}` + "\n" + complete := cond(batchv1.JobComplete, corev1.ConditionTrue, "") + + cases := []struct { + name string + conds []batchv1.JobCondition + log string + state string + reason string + code string + files int + done int64 + finished bool + wantResult bool + }{ + {name: "running, at its latest progress", log: progress, state: OpRunning, done: 40}, + {name: "running, before any progress", state: OpRunning}, + {name: "a condition not yet true is still running", + conds: []batchv1.JobCondition{cond(batchv1.JobFailed, corev1.ConditionFalse, "DeadlineExceeded")}, + log: progress, state: OpRunning, done: 40}, + {name: "complete with a clean result", + conds: []batchv1.JobCondition{complete}, log: progress + ok, + state: OpSucceeded, files: 3, done: 40, finished: true, wantResult: true}, + {name: "success criteria met before complete", + conds: []batchv1.JobCondition{cond(batchv1.JobSuccessCriteriaMet, corev1.ConditionTrue, "")}, log: ok, + state: OpSucceeded, files: 3, finished: true, wantResult: true}, + {name: "killed at its deadline after printing a clean result", + conds: []batchv1.JobCondition{cond(batchv1.JobFailed, corev1.ConditionTrue, "DeadlineExceeded")}, log: ok, + state: OpSucceeded, files: 3, finished: true, wantResult: true}, + {name: "complete with a refusal", + conds: []batchv1.JobCondition{complete}, log: conflict, + state: OpFailed, code: CodeExists, finished: true, wantResult: true}, + {name: "killed at its deadline without a result", + conds: []batchv1.JobCondition{cond(batchv1.JobFailed, corev1.ConditionTrue, "DeadlineExceeded")}, log: progress, + state: OpFailed, reason: "DeadlineExceeded", done: 40, finished: true}, + {name: "failure target before failed", + conds: []batchv1.JobCondition{cond(batchv1.JobFailureTarget, corev1.ConditionTrue, "BackoffLimitExceeded")}, + state: OpFailed, reason: "BackoffLimitExceeded", finished: true}, + {name: "failed without a reason", + conds: []batchv1.JobCondition{cond(batchv1.JobFailed, corev1.ConditionTrue, "")}, + state: OpFailed, reason: "Failed", finished: true}, + {name: "complete but its log is gone", + conds: []batchv1.JobCondition{complete}, + state: OpFailed, reason: "ResultUnavailable", finished: true}, + {name: "complete with a result that does not parse", + conds: []batchv1.JobCondition{complete}, log: ResultPrefix + "{\n", + state: OpFailed, reason: "ResultUnavailable", finished: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + st := opState(asyncJob(tc.conds...), tc.log) + if st.ID != "0a" || st.Op != OpUnzip || st.Path != "maps/world.zip" || !st.Started.Equal(opCreated) { + t.Fatalf("identity = %q %q %q %v", st.ID, st.Op, st.Path, st.Started) + } + if st.State != tc.state || st.Reason != tc.reason { + t.Fatalf("state = %q reason %q, want %q reason %q", st.State, st.Reason, tc.state, tc.reason) + } + if st.Done != tc.done || (tc.done != 0 && st.Total != 100) { + t.Fatalf("progress = %d/%d, want %d/100", st.Done, st.Total, tc.done) + } + if want := map[bool]time.Time{true: opEnded}[tc.finished]; !st.Finished.Equal(want) { + t.Fatalf("finished = %v, want %v", st.Finished, want) + } + if (st.Result != nil) != tc.wantResult { + t.Fatalf("result = %+v, want one: %v", st.Result, tc.wantResult) + } + if st.Result != nil && (st.Result.Code != tc.code || st.Result.Files != tc.files) { + t.Fatalf("result = %+v, want code %q and %d files", st.Result, tc.code, tc.files) + } + }) + } +} + +// TestK8sRunnerOps checks what Ops lists: this server's background Jobs only, +// newest first and at most maxOps of them, with a log read for each started Pod +// and none for a Pod still waiting to run. +func TestK8sRunnerOps(t *testing.T) { + job := func(server, id string, age time.Duration, async bool) *batchv1.Job { + p := testParams(OpUnzip) + p.Server, p.OpID, p.Path, p.Async = server, id, "maps/"+id+".zip", async + j, err := FilesJob(p) + if err != nil { + t.Fatal(err) + } + j.CreationTimestamp = metav1.NewTime(opCreated.Add(-age)) + return j + } + pod := func(j *batchv1.Job, phase corev1.PodPhase, age time.Duration) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%d", j.Name, age), Namespace: "minecraft", Labels: j.Spec.Template.Labels, + CreationTimestamp: metav1.NewTime(opCreated.Add(-age)), + }, + Status: corev1.PodStatus{Phase: phase}, + } + } + + var objs []runtime.Object + for i := range maxOps + 2 { + j := job("survival", fmt.Sprintf("%02d", i), time.Duration(i)*time.Minute, true) + objs = append(objs, j, pod(j, corev1.PodSucceeded, time.Duration(i)*time.Minute)) + } + // The newest Job's newest Pod has not started, so its log is not read; the + // older Pod beside it is not the one Ops reports on. + newest := job("survival", "new", -time.Minute, true) + objs = append(objs, newest, + pod(newest, corev1.PodPending, -time.Minute), pod(newest, corev1.PodFailed, 0)) + objs = append(objs, + job("creative", "other", -2*time.Minute, true), + job("survival", "sync", -3*time.Minute, false)) + cs := fake.NewSimpleClientset(objs...) + + ops, err := NewK8sRunner(cs).Ops(context.Background(), "minecraft", "survival") + if err != nil { + t.Fatalf("Ops: %v", err) + } + var ids []string + for _, op := range ops { + ids = append(ids, op.ID) + } + want := []string{"new", "00", "01", "02", "03", "04", "05", "06", "07", "08"} + if fmt.Sprint(ids) != fmt.Sprint(want) { + t.Fatalf("ops = %v, want %v", ids, want) + } + if ops[0].Path != "maps/new.zip" || ops[0].Op != OpUnzip { + t.Fatalf("newest op = %+v", ops[0]) + } + + logs := 0 + for _, a := range cs.Actions() { + if a.GetVerb() == "get" && a.GetSubresource() == "log" { + logs++ + opts := a.(k8stesting.GenericAction).GetValue().(*corev1.PodLogOptions) + if opts.Container != containerName || opts.TailLines == nil || *opts.TailLines != opLogLines { + t.Fatalf("log options = %+v", opts) + } + } + } + if logs != maxOps-1 { + t.Fatalf("read %d logs, want %d: one per listed op whose Pod has started", logs, maxOps-1) + } +} + +// TestK8sRunnerOpsFailsLoudly checks a listing the cluster refused is an error, +// never an empty list that would read as nothing running. +func TestK8sRunnerOpsFailsLoudly(t *testing.T) { + for _, resource := range []string{"jobs", "pods"} { + t.Run(resource, func(t *testing.T) { + cs := fake.NewSimpleClientset() + cs.PrependReactor("list", resource, func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("forbidden") + }) + ops, err := NewK8sRunner(cs).Ops(context.Background(), "minecraft", "survival") + if err == nil || ops != nil { + t.Fatalf("Ops = %v, %v; want the refusal", ops, err) + } + }) + } +} + +// TestK8sRunnerStart checks Start creates the rendered Job and nothing else, +// and refuses params the renderer refuses without touching the cluster. +func TestK8sRunnerStart(t *testing.T) { + cs := fake.NewSimpleClientset() + p := testParams(OpUnzip) + p.Path, p.Async = "maps/world.zip", true + if err := NewK8sRunner(cs).Start(context.Background(), p); err != nil { + t.Fatalf("Start: %v", err) + } + want, _ := FilesJob(p) + got, err := cs.BatchV1().Jobs("minecraft").Get(context.Background(), want.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("the Job was not created: %v", err) + } + if got.Labels[LabelAsync] != "true" || got.Annotations[AnnotationPath] != "maps/world.zip" { + t.Fatalf("created %+v", got.ObjectMeta) + } + if n := len(cs.Actions()); n != 2 { // the create, and this test's get + t.Fatalf("%d calls to the cluster, want the one create", n-1) + } + + cs = fake.NewSimpleClientset() + p.Op = OpRead + if err := NewK8sRunner(cs).Start(context.Background(), p); err == nil || len(cs.Actions()) != 0 { + t.Fatalf("err %v after %d calls, want a refusal before any", err, len(cs.Actions())) + } + + cs = fake.NewSimpleClientset() + cs.PrependReactor("create", "jobs", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("quota exceeded") + }) + p.Op = OpUnzip + if err := NewK8sRunner(cs).Start(context.Background(), p); err == nil { + t.Fatal("a refused create must be an error") + } +} diff --git a/internal/fileedit/manage_test.go b/internal/fileedit/manage_test.go index 1b6f06a..6671d45 100644 --- a/internal/fileedit/manage_test.go +++ b/internal/fileedit/manage_test.go @@ -491,17 +491,39 @@ func TestUpload(t *testing.T) { } }) - t.Run("over the cap is too_large and never fetched; at the cap is fetched", func(t *testing.T) { + t.Run("more than the volume has free is no_space and never fetched; exactly the free room is fetched", func(t *testing.T) { root, _ := worldRoot(t) + stubStatfs(t, uint64(len(jar)), 1<<30) src := &fakeSource{body: jar} - res, err := send(t, root, "big.jar", src.upload(MaxUploadBytes+1, ""), false) - if err != nil || res.Code != CodeTooLarge || src.opened != 0 { - t.Fatalf("result = %+v, %v, opened %d", res, err, src.opened) + res, err := send(t, root, "big.jar", src.upload(int64(len(jar))+1, ""), false) + if err != nil || res.Code != CodeNoSpace || res.Need != int64(len(jar))+1 || res.Avail != int64(len(jar)) || src.opened != 0 { + t.Fatalf("result = %+v, %v, opened %d; want no_space with need %d, avail %d", res, err, src.opened, len(jar)+1, len(jar)) } - // At the cap the size passes and the transfer starts; this source then - // comes up short, which is a broken transfer rather than a refusal. - if _, err := send(t, root, "big.jar", src.upload(MaxUploadBytes, ""), false); err == nil || src.opened != 1 { - t.Fatalf("at the cap: err = %v, opened %d; want a fetch", err, src.opened) + assertAbsent(t, filepath.Join(root, "big.jar")) + if res, err := send(t, root, "big.jar", whole(src), false); err != nil || res.Code != "" || src.opened != 1 { + t.Fatalf("at the free room: result = %+v, %v, opened %d; want it landed", res, err, src.opened) + } + }) + + // The editor used to stop at MaxUploadBytes; a file sent in parts is bounded + // by the volume alone. + t.Run("past the single-request limit is fetched", func(t *testing.T) { + root, _ := worldRoot(t) + stubStatfs(t, 1<<40, 1<<41) + src := &fakeSource{body: jar} + // This source then comes up short, which is a broken transfer, not a refusal. + if _, err := send(t, root, "big.jar", src.upload(MaxUploadBytes+1, ""), false); err == nil || src.opened != 1 { + t.Fatalf("err = %v, opened %d; want a fetch", err, src.opened) + } + }) + + t.Run("progress hears every byte", func(t *testing.T) { + root, _ := worldRoot(t) + var last, calls, total int64 + res := exec(t, root, Request{Op: OpUpload, Path: "x.jar", Upload: whole(&fakeSource{body: jar}), + Progress: func(done, all int64) { calls++; last, total = done, all }}) + if res.Code != "" || calls == 0 || last != int64(len(jar)) || total != int64(len(jar)) { + t.Fatalf("result = %+v; progress calls %d, last %d of %d; want the whole %d", res, calls, last, total, len(jar)) } }) diff --git a/internal/fileedit/progress.go b/internal/fileedit/progress.go new file mode 100644 index 0000000..fbb7d51 --- /dev/null +++ b/internal/fileedit/progress.go @@ -0,0 +1,61 @@ +package fileedit + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +// ProgressPrefix marks a progress line: how many of an upload's or an unzip's +// bytes are in so far, as JSON. Those two run as Jobs felis-api does not wait on, +// and the panel shows how far one has got by reading the latest such line from +// the tail of the Pod's log. Like ResultPrefix it is found by its marker, since +// the log is stdout and stderr merged. +const ProgressPrefix = "FELIS-FILES-PROGRESS: " + +// Progress is one progress line. +type Progress struct { + Done int64 `json:"done"` + Total int64 `json:"total"` +} + +// ThrottledProgress returns a progress func that prints to w at most once per +// every, plus the first call (the last print starts at the zero time) and the +// one that reaches the total, so the log grows by a line a second however fast +// the bytes move and still ends on the true final count. A write error is +// dropped: progress is a courtesy, and the result line that follows is what +// felis-api acts on. +func ThrottledProgress(w io.Writer, every time.Duration, now func() time.Time) func(done, total int64) { + var last time.Time + return func(done, total int64) { + t := now() + if done < total && t.Sub(last) < every { + return + } + last = t + b, _ := json.Marshal(Progress{Done: done, Total: total}) + fmt.Fprintf(w, "%s%s\n", ProgressPrefix, b) + } +} + +// lastProgress finds the latest well-formed progress line in a log. +func lastProgress(log string) (Progress, bool) { + var p Progress + found := false + sc := bufio.NewScanner(strings.NewReader(log)) + sc.Buffer(make([]byte, 0, 4096), maxLogBytes) + for sc.Scan() { + rest, ok := strings.CutPrefix(sc.Text(), ProgressPrefix) + if !ok { + continue + } + var q Progress + if json.Unmarshal([]byte(rest), &q) == nil { + p, found = q, true + } + } + return p, found +} diff --git a/internal/fileedit/progress_test.go b/internal/fileedit/progress_test.go new file mode 100644 index 0000000..c3c66e8 --- /dev/null +++ b/internal/fileedit/progress_test.go @@ -0,0 +1,42 @@ +package fileedit + +import ( + "bytes" + "testing" + "time" +) + +func TestThrottledProgress(t *testing.T) { + var out bytes.Buffer + clock := time.Unix(1000, 0) + progress := ThrottledProgress(&out, time.Second, func() time.Time { return clock }) + + progress(0, 100) // first call: printed + progress(10, 100) // same instant: dropped + clock = clock.Add(999 * time.Millisecond) + progress(20, 100) // not a second yet: dropped + clock = clock.Add(time.Millisecond) + progress(30, 100) // a second on: printed + progress(100, 100) // the total, however soon: printed + want := ProgressPrefix + `{"done":0,"total":100}` + "\n" + + ProgressPrefix + `{"done":30,"total":100}` + "\n" + + ProgressPrefix + `{"done":100,"total":100}` + "\n" + if out.String() != want { + t.Fatalf("printed:\n%s\nwant:\n%s", out.String(), want) + } +} + +func TestLastProgress(t *testing.T) { + log := "noise\n" + + ProgressPrefix + `{"done":1,"total":9}` + "\n" + + "a runtime warning on stderr\n" + + ProgressPrefix + `{"done":5,"total":9}` + "\n" + + ProgressPrefix + `{"done":` + "\n" // cut off mid-line by the tail + p, ok := lastProgress(log) + if !ok || p != (Progress{Done: 5, Total: 9}) { + t.Fatalf("lastProgress = %+v, %v; want {5 9}, true", p, ok) + } + if _, ok := lastProgress("no marker here\n"); ok { + t.Fatal("a log without a progress line reported one") + } +} diff --git a/internal/fileedit/session.go b/internal/fileedit/session.go new file mode 100644 index 0000000..ff556e5 --- /dev/null +++ b/internal/fileedit/session.go @@ -0,0 +1,328 @@ +package fileedit + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding" + "errors" + "fmt" + "hash" + "io" + "os" + "time" +) + +// A file too big for one request body arrives as a session: Begin declares its +// size and where it goes, Append adds one part at a time in order, and Seal +// hands the finished file to the Job that lands it, which fetches it through +// Open like any other staged upload. The Cloudflare edge refuses bodies over +// 100 MB, so a part is at most PartBytes; the file itself has no ceiling but the +// room on the staging disk, and Begin reserves all of it up front, so an upload +// that starts is one the disk can finish. +// +// Every call names the user and the server the session was begun for, and a +// session answers no one else: an id that is someone else's reads as unknown. +// +// A part that fails midway (the connection dropped, the edge cut it off) is +// rolled back to where it started, so the session's length is always the resume +// point. A sealed session stays until it has been served whole once (Served), so +// a Job that failed before it had every byte can be started again without the +// file being sent again; one left idle for SessionIdle is dropped (Expire). + +// PartBytes is the largest part Append takes, matching the modpack upload's +// parts (submit.DefaultPartMaxBytes). +const PartBytes = 32 << 20 + +// SessionIdle is how long a session may sit untouched before Expire drops it: +// long enough to resume after a lost connection or a laptop lid, short enough +// that an abandoned upload gives its room back the same day. +const SessionIdle = 6 * time.Hour + +// MaxSessionsPerUser bounds the sessions one user holds open at once. Each +// reserves its whole size on the staging disk, so without a bound one user +// could reserve the disk out from under everyone for SessionIdle. +const MaxSessionsPerUser = 4 + +var ( + // ErrTooManySessions is a Begin by a user who already holds + // MaxSessionsPerUser sessions. + ErrTooManySessions = errors.New("fileedit: too many uploads in progress") + // ErrUploadBusy is a call on a session another request is still appending + // to. Parts go one at a time. + ErrUploadBusy = errors.New("fileedit: another request is still writing this upload") + // ErrPartTooLarge is a part over PartBytes, or one that runs past the size + // the session was begun with. + ErrPartTooLarge = errors.New("fileedit: the part is too large") + // ErrUploadIncomplete is a Seal before every byte has arrived. + ErrUploadIncomplete = errors.New("fileedit: the upload has not finished arriving") +) + +// OffsetError is a part that does not start where the session ends. Received is +// where it does end, so the client resumes from there. +type OffsetError struct{ Received int64 } + +func (e *OffsetError) Error() string { + return fmt.Sprintf("fileedit: the upload holds %d bytes; send the part that starts there", e.Received) +} + +// Session is where one session stands. +type Session struct { + ID string + Path string + Size int64 + Received int64 +} + +type session struct { + user, server, path string + file string + size, received int64 + h hash.Hash + busy bool + touched time.Time + + // armed is set by Seal with the digest of the token it minted and cleared by + // the Open that spends it. + armed bool + tokenHash [sha256.Size]byte +} + +func (s *Stage) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +// Begin opens a session for a file of size bytes that will land at path on +// server, reserving room for all of it. +func (s *Stage) Begin(user, server, path string, size int64) (Session, error) { + if size < 0 { + return Session{}, fmt.Errorf("fileedit: an upload of %d bytes", size) + } + id, err := randomHex(16) + if err != nil { + return Session{}, fmt.Errorf("fileedit: generate an upload id: %w", err) + } + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return Session{}, fmt.Errorf("fileedit: create the upload stage: %w", err) + } + if err := s.reserve(size); err != nil { + return Session{}, err + } + f, err := os.CreateTemp(s.Dir, "session-*") + if err != nil { + s.unreserve(size) + return Session{}, fmt.Errorf("fileedit: stage the upload: %w", err) + } + f.Close() + + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for _, ss := range s.sessions { + if ss.user == user { + n++ + } + } + if n >= MaxSessionsPerUser { + s.reserved -= size + os.Remove(f.Name()) + return Session{}, fmt.Errorf("%w: finish or cancel one of your %d uploads first", ErrTooManySessions, n) + } + if s.sessions == nil { + s.sessions = map[string]*session{} + } + s.sessions[id] = &session{ + user: user, server: server, path: path, file: f.Name(), + size: size, h: sha256.New(), touched: s.now(), + } + return Session{ID: id, Path: path, Size: size}, nil +} + +// lookup finds the caller's session. s.mu must be held. +func (s *Stage) lookup(user, server, id string) (*session, error) { + ss, ok := s.sessions[id] + if !ok || ss.user != user || ss.server != server { + return nil, ErrNotStaged + } + return ss, nil +} + +func (ss *session) view(id string) Session { + return Session{ID: id, Path: ss.path, Size: ss.size, Received: ss.received} +} + +// Status reports where the caller's session stands. +func (s *Stage) Status(user, server, id string) (Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + ss, err := s.lookup(user, server, id) + if err != nil { + return Session{}, err + } + return ss.view(id), nil +} + +// Append adds the n bytes of body at offset, which must be where the session +// ends. body must end right after them (an HTTP body of that Content-Length +// does). On any failure the session is left as it was before the call. +func (s *Stage) Append(user, server, id string, offset int64, body io.Reader, n int64) (Session, error) { + s.mu.Lock() + ss, err := s.lookup(user, server, id) + switch { + case err != nil: + case ss.busy: + err = ErrUploadBusy + case offset != ss.received: + err = &OffsetError{Received: ss.received} + case n < 0 || n > PartBytes || n > ss.size-ss.received: + err = fmt.Errorf("%w: %d bytes at %d of a %d-byte upload; parts are at most %d bytes", + ErrPartTooLarge, n, offset, ss.size, PartBytes) + } + if err != nil { + var view Session + if ss != nil { + view = ss.view(id) + } + s.mu.Unlock() + return view, err + } + ss.busy = true + s.mu.Unlock() + + // The session is ours until busy is cleared, so the file and the hash are + // touched without the lock. The hash's state is kept to undo a failed part. + before, err := ss.h.(encoding.BinaryMarshaler).MarshalBinary() + if err == nil { + err = appendPart(ss.file, offset, body, n, ss.h) + if err != nil { + _ = os.Truncate(ss.file, offset) + _ = ss.h.(encoding.BinaryUnmarshaler).UnmarshalBinary(before) + } + } + + s.mu.Lock() + defer s.mu.Unlock() + ss.busy = false + ss.touched = s.now() + if err != nil { + return ss.view(id), err + } + ss.received += n + s.reserved -= n + return ss.view(id), nil +} + +// appendPart writes exactly n bytes of body at offset in the file named file, +// feeding them to h as well. +func appendPart(file string, offset int64, body io.Reader, n int64, h hash.Hash) error { + f, err := os.OpenFile(file, os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("fileedit: open the staged upload: %w", err) + } + src := &bodyReader{r: body} + // One byte past n, so the read that finds the end happens here. + got, copyErr := io.Copy(io.MultiWriter(io.NewOffsetWriter(f, offset), h), io.LimitReader(src, n+1)) + closeErr := f.Close() + return stageFailure(src.err, copyErr, closeErr, got, n) +} + +// Seal ends the caller's session and arms it for one fetch: the Staged it +// returns carries a fresh token, and any token an earlier Seal minted stops +// working. Every byte must have arrived. +func (s *Stage) Seal(user, server, id string) (Staged, error) { + s.mu.Lock() + defer s.mu.Unlock() + ss, err := s.lookup(user, server, id) + if err != nil { + return Staged{}, err + } + if ss.busy { + return Staged{}, ErrUploadBusy + } + if ss.received != ss.size { + return Staged{}, fmt.Errorf("%w: %d of %d bytes are here", ErrUploadIncomplete, ss.received, ss.size) + } + st, tokenHash, err := newHandle(ss.h, ss.size) + if err != nil { + return Staged{}, err + } + st.ID = id + ss.armed, ss.tokenHash = true, tokenHash + ss.touched = s.now() + return st, nil +} + +// openSession is Open for a sealed session. s.mu must be held; ok is false when +// id names no session. +func (s *Stage) openSession(id string, sum [sha256.Size]byte) (path string, size int64, ok bool, err error) { + ss, found := s.sessions[id] + if !found { + return "", 0, false, nil + } + if !ss.armed || subtle.ConstantTimeCompare(sum[:], ss.tokenHash[:]) != 1 { + return "", 0, true, ErrNotStaged + } + ss.armed = false + ss.touched = s.now() + return ss.file, ss.size, true, nil +} + +// Served tells the stage the session id was sent whole to the Job that opened +// it, and deletes it: its bytes are on the Job's side now. An id that names no +// session (an upload staged by Put, which its own release deletes) is ignored. +func (s *Stage) Served(id string) { + s.mu.Lock() + ss, ok := s.sessions[id] + if ok { + delete(s.sessions, id) + } + s.mu.Unlock() + if ok { + os.Remove(ss.file) + } +} + +// Drop cancels the caller's session and deletes what it holds. +func (s *Stage) Drop(user, server, id string) error { + s.mu.Lock() + ss, err := s.lookup(user, server, id) + if err == nil && ss.busy { + err = ErrUploadBusy + } + if err != nil { + s.mu.Unlock() + return err + } + s.dropLocked(id, ss) + s.mu.Unlock() + os.Remove(ss.file) + return nil +} + +// dropLocked forgets a session and gives back the room it still had reserved. +// s.mu must be held; the caller deletes the file. +func (s *Stage) dropLocked(id string, ss *session) { + delete(s.sessions, id) + s.reserved -= ss.size - ss.received +} + +// Expire drops every session untouched for SessionIdle, sealed or not, and +// reports how many it dropped. A session a part is arriving for is never idle. +func (s *Stage) Expire() int { + cutoff := s.now().Add(-SessionIdle) + var files []string + s.mu.Lock() + for id, ss := range s.sessions { + if !ss.busy && ss.touched.Before(cutoff) { + s.dropLocked(id, ss) + files = append(files, ss.file) + } + } + s.mu.Unlock() + for _, f := range files { + os.Remove(f) + } + return len(files) +} diff --git a/internal/fileedit/session_test.go b/internal/fileedit/session_test.go new file mode 100644 index 0000000..7fbab0a --- /dev/null +++ b/internal/fileedit/session_test.go @@ -0,0 +1,525 @@ +package fileedit + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// diskStage is a stage on a disk of total bytes whose free space is what the +// files in it leave of free: statfs sees parts land, as a real disk would. +func diskStage(t *testing.T, free, total uint64, minFree float64) *Stage { + t.Helper() + s := &Stage{Dir: filepath.Join(t.TempDir(), "stage"), MinFree: minFree} + prev := statfs + statfs = func(string) (uint64, uint64, error) { + var used uint64 + des, _ := os.ReadDir(s.Dir) + for _, de := range des { + if info, err := de.Info(); err == nil { + used += uint64(info.Size()) + } + } + return free - used, total, nil + } + t.Cleanup(func() { statfs = prev }) + return s +} + +func appendString(s *Stage, user, server, id string, offset int64, part string) (Session, error) { + return s.Append(user, server, id, offset, strings.NewReader(part), int64(len(part))) +} + +func readStaged(t *testing.T, s *Stage, id, token string) string { + t.Helper() + f, size, err := s.Open(id, token) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer f.Close() + b, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if size != int64(len(b)) { + t.Fatalf("Open said %d bytes and served %d", size, len(b)) + } + return string(b) +} + +// TestSessionArrivesInParts: parts land in order, Seal hands the Job a token for +// exactly those bytes, and Served deletes them. +func TestSessionArrivesInParts(t *testing.T) { + s := roomyStage(t) + const whole = "PK\x03\x04 first part, second part" + sess, err := s.Begin("u1", "survival", "plugins/big.jar", int64(len(whole))) + if err != nil { + t.Fatalf("Begin: %v", err) + } + if !hexID.MatchString(sess.ID) || sess != (Session{ID: sess.ID, Path: "plugins/big.jar", Size: int64(len(whole))}) { + t.Fatalf("Begin = %+v", sess) + } + got, err := appendString(s, "u1", "survival", sess.ID, 0, whole[:16]) + if err != nil || got.Received != 16 || got.Size != int64(len(whole)) { + t.Fatalf("first part: %+v, %v", got, err) + } + if at, err := s.Status("u1", "survival", sess.ID); err != nil || at.Received != 16 || at.Path != "plugins/big.jar" { + t.Fatalf("Status = %+v, %v", at, err) + } + if got, err = appendString(s, "u1", "survival", sess.ID, 16, whole[16:]); err != nil || got.Received != int64(len(whole)) { + t.Fatalf("second part: %+v, %v", got, err) + } + + st, err := s.Seal("u1", "survival", sess.ID) + if err != nil { + t.Fatalf("Seal: %v", err) + } + if st.ID != sess.ID || !hexToken.MatchString(st.Token) || st.Size != int64(len(whole)) || st.SHA256 != digest([]byte(whole)) { + t.Fatalf("Seal = %+v, want the digest of %q", st, whole) + } + if body := readStaged(t, s, st.ID, st.Token); body != whole { + t.Fatalf("served %q, want %q", body, whole) + } + if _, _, err := s.Open(st.ID, st.Token); !errors.Is(err, ErrNotStaged) { + t.Fatalf("second Open with the same token: err = %v, want ErrNotStaged", err) + } + + s.Served(st.ID) + if names := stagedNames(t, s); len(names) != 0 { + t.Fatalf("after Served: %v", names) + } + if _, err := s.Status("u1", "survival", sess.ID); !errors.Is(err, ErrNotStaged) { + t.Fatalf("Status after Served: err = %v, want ErrNotStaged", err) + } +} + +// A session answers only the user and the server it was begun for. +func TestSessionAnswersItsOwnerOnly(t *testing.T) { + s := roomyStage(t) + sess, err := s.Begin("u1", "survival", "a.zip", 4) + if err != nil { + t.Fatal(err) + } + for name, who := range map[string][2]string{ + "another user": {"u2", "survival"}, + "another server": {"u1", "creative"}, + } { + if _, err := s.Status(who[0], who[1], sess.ID); !errors.Is(err, ErrNotStaged) { + t.Errorf("%s: Status err = %v", name, err) + } + if _, err := appendString(s, who[0], who[1], sess.ID, 0, "abcd"); !errors.Is(err, ErrNotStaged) { + t.Errorf("%s: Append err = %v", name, err) + } + if _, err := s.Seal(who[0], who[1], sess.ID); !errors.Is(err, ErrNotStaged) { + t.Errorf("%s: Seal err = %v", name, err) + } + if err := s.Drop(who[0], who[1], sess.ID); !errors.Is(err, ErrNotStaged) { + t.Errorf("%s: Drop err = %v", name, err) + } + } + if _, err := s.Status("u1", "survival", strings.Repeat("0", 32)); !errors.Is(err, ErrNotStaged) { + t.Errorf("unknown id: err = %v", err) + } + if at, err := s.Status("u1", "survival", sess.ID); err != nil || at.Received != 0 { + t.Fatalf("the owner's session after the others tried: %+v, %v", at, err) + } +} + +func TestSessionRefusesAPartThatDoesNotFit(t *testing.T) { + s := roomyStage(t) + sess, err := s.Begin("u1", "survival", "a.zip", 6) + if err != nil { + t.Fatal(err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 0, "abc"); err != nil { + t.Fatal(err) + } + + var off *OffsetError + for _, offset := range []int64{0, 2, 4} { + at, err := appendString(s, "u1", "survival", sess.ID, offset, "d") + if !errors.As(err, &off) || off.Received != 3 || at.Received != 3 { + t.Fatalf("offset %d: %+v, err = %v; want an OffsetError at 3", offset, at, err) + } + } + if _, err := appendString(s, "u1", "survival", sess.ID, 3, "defg"); !errors.Is(err, ErrPartTooLarge) { + t.Fatalf("past the declared size: err = %v, want ErrPartTooLarge", err) + } + if _, err := s.Append("u1", "survival", sess.ID, 3, strings.NewReader(""), -1); !errors.Is(err, ErrPartTooLarge) { + t.Fatalf("negative length: err = %v, want ErrPartTooLarge", err) + } + if at, err := appendString(s, "u1", "survival", sess.ID, 3, "def"); err != nil || at.Received != 6 { + t.Fatalf("the part that fits exactly: %+v, %v", at, err) + } + + big, err := s.Begin("u1", "survival", "b.zip", PartBytes+2) + if err != nil { + t.Fatal(err) + } + if _, err := s.Append("u1", "survival", big.ID, 0, strings.NewReader(""), PartBytes+1); !errors.Is(err, ErrPartTooLarge) { + t.Fatalf("a part over PartBytes: err = %v, want ErrPartTooLarge", err) + } +} + +// A part that breaks or runs long leaves the session as it was: the file is cut +// back and the digest forgets it, so the resent part makes the right file. +func TestSessionRollsBackAFailedPart(t *testing.T) { + for name, tc := range map[string]struct { + body io.Reader + short bool + }{ + "breaks": {io.MultiReader(strings.NewReader("XY"), errReader{io.ErrUnexpectedEOF}), true}, + "ends": {strings.NewReader("XY"), true}, + "runs long": {strings.NewReader("XYZWV"), false}, + } { + t.Run(name, func(t *testing.T) { + s := roomyStage(t) + sess, err := s.Begin("u1", "survival", "a.zip", 7) + if err != nil { + t.Fatal(err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 0, "abc"); err != nil { + t.Fatal(err) + } + at, err := s.Append("u1", "survival", sess.ID, 3, tc.body, 4) + if err == nil || errors.Is(err, ErrShortUpload) != tc.short || at.Received != 3 { + t.Fatalf("%+v, err = %v; want a failure at 3 (short = %v)", at, err, tc.short) + } + info, err := os.Stat(filepath.Join(s.Dir, stagedNames(t, s)[0])) + if err != nil || info.Size() != 3 { + t.Fatalf("staged file is %v bytes (%v), want it cut back to 3", info.Size(), err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 3, "defg"); err != nil { + t.Fatalf("resent part: %v", err) + } + st, err := s.Seal("u1", "survival", sess.ID) + if err != nil || st.SHA256 != digest([]byte("abcdefg")) { + t.Fatalf("Seal = %+v, %v; want the digest of abcdefg", st, err) + } + if body := readStaged(t, s, st.ID, st.Token); body != "abcdefg" { + t.Fatalf("served %q", body) + } + }) + } +} + +// While a part is arriving nothing else may touch the session, and it is never +// idle. +func TestSessionIsBusyWhileAPartArrives(t *testing.T) { + s := roomyStage(t) + now := time.Date(2026, 9, 28, 12, 0, 0, 0, time.UTC) + s.Now = func() time.Time { return now } + sess, err := s.Begin("u1", "survival", "a.zip", 4) + if err != nil { + t.Fatal(err) + } + pr, pw := io.Pipe() + done := make(chan error, 1) + go func() { + _, err := s.Append("u1", "survival", sess.ID, 0, pr, 4) + done <- err + }() + // The write returns once Append is copying, which is after it marked busy. + if _, err := pw.Write([]byte("ab")); err != nil { + t.Fatal(err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 0, "abcd"); !errors.Is(err, ErrUploadBusy) { + t.Errorf("a second part: err = %v, want ErrUploadBusy", err) + } + if _, err := s.Seal("u1", "survival", sess.ID); !errors.Is(err, ErrUploadBusy) { + t.Errorf("Seal: err = %v, want ErrUploadBusy", err) + } + if err := s.Drop("u1", "survival", sess.ID); !errors.Is(err, ErrUploadBusy) { + t.Errorf("Drop: err = %v, want ErrUploadBusy", err) + } + now = now.Add(SessionIdle + time.Hour) + if n := s.Expire(); n != 0 { + t.Errorf("Expire dropped %d sessions with a part arriving", n) + } + if _, err := pw.Write([]byte("cd")); err != nil { + t.Fatal(err) + } + pw.Close() + if err := <-done; err != nil { + t.Fatalf("the part in flight: %v", err) + } + if _, err := s.Seal("u1", "survival", sess.ID); err != nil { + t.Fatalf("Seal once the part is in: %v", err) + } +} + +// Each Seal arms one fetch with a fresh token, so a Job that failed can be +// started again on the same bytes. +func TestSessionSealArmsOneFetch(t *testing.T) { + s := roomyStage(t) + sess, err := s.Begin("u1", "survival", "a.zip", 4) + if err != nil { + t.Fatal(err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 0, "abc"); err != nil { + t.Fatal(err) + } + if _, err := s.Seal("u1", "survival", sess.ID); !errors.Is(err, ErrUploadIncomplete) { + t.Fatalf("Seal at 3 of 4: err = %v, want ErrUploadIncomplete", err) + } + if _, _, err := s.Open(sess.ID, ""); !errors.Is(err, ErrNotStaged) { + t.Fatalf("Open before any Seal: err = %v, want ErrNotStaged", err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 3, "d"); err != nil { + t.Fatal(err) + } + + first, err := s.Seal("u1", "survival", sess.ID) + if err != nil { + t.Fatal(err) + } + second, err := s.Seal("u1", "survival", sess.ID) + if err != nil { + t.Fatal(err) + } + if first.Token == second.Token || first.SHA256 != second.SHA256 { + t.Fatalf("two Seals: %+v and %+v; want fresh tokens for the same bytes", first, second) + } + if _, _, err := s.Open(sess.ID, first.Token); !errors.Is(err, ErrNotStaged) { + t.Fatalf("the replaced token: err = %v, want ErrNotStaged", err) + } + if _, _, err := s.Open(sess.ID, strings.Repeat("0", 64)); !errors.Is(err, ErrNotStaged) { + t.Fatalf("a wrong token: err = %v, want ErrNotStaged", err) + } + // Neither wrong token spent the armed one. + if body := readStaged(t, s, sess.ID, second.Token); body != "abcd" { + t.Fatalf("served %q", body) + } + if _, _, err := s.Open(sess.ID, second.Token); !errors.Is(err, ErrNotStaged) { + t.Fatalf("the spent token: err = %v, want ErrNotStaged", err) + } + + // Opened but not served whole: the Job broke midway, and a new Seal serves + // the same bytes again. + third, err := s.Seal("u1", "survival", sess.ID) + if err != nil { + t.Fatal(err) + } + if body := readStaged(t, s, sess.ID, third.Token); body != "abcd" { + t.Fatalf("served %q after a new Seal", body) + } +} + +// Served deletes only sessions: an upload staged by Put belongs to the release +// func Put returned. +func TestServedLeavesPutAlone(t *testing.T) { + s := roomyStage(t) + st, release, err := s.Put(strings.NewReader("abc"), 3) + if err != nil { + t.Fatal(err) + } + defer release() + s.Served(st.ID) + if body := readStaged(t, s, st.ID, st.Token); body != "abc" { + t.Fatalf("served %q", body) + } +} + +// A session reserves its whole size when it begins, and gives back what it has +// not yet received when it is dropped or expires. +func TestSessionReservesItsSize(t *testing.T) { + t.Run("begin reserves the whole size", func(t *testing.T) { + s := diskStage(t, 1000, 1200, 0.5) // room for 400 + if _, err := s.Begin("u1", "survival", "a.zip", 300); err != nil { + t.Fatal(err) + } + if _, err := s.Begin("u2", "survival", "b.zip", 101); !errors.Is(err, ErrStageFull) { + t.Fatalf("101 bytes beside a 300-byte session: err = %v, want ErrStageFull", err) + } + if _, err := s.Begin("u2", "survival", "b.zip", 100); err != nil { + t.Fatalf("100 bytes beside a 300-byte session: %v", err) + } + }) + + t.Run("a part moves its room from the reservation to the disk", func(t *testing.T) { + s := diskStage(t, 1000, 1200, 0.5) + sess, err := s.Begin("u1", "survival", "a.zip", 300) + if err != nil { + t.Fatal(err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 0, strings.Repeat("x", 200)); err != nil { + t.Fatal(err) + } + if _, err := s.Begin("u2", "survival", "b.zip", 101); !errors.Is(err, ErrStageFull) { + t.Fatalf("after a part landed: err = %v, want ErrStageFull", err) + } + if _, err := s.Begin("u2", "survival", "b.zip", 100); err != nil { + t.Fatalf("after a part landed: %v", err) + } + }) + + t.Run("drop gives it all back", func(t *testing.T) { + s := diskStage(t, 1000, 1200, 0.5) + sess, err := s.Begin("u1", "survival", "a.zip", 300) + if err != nil { + t.Fatal(err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 0, strings.Repeat("x", 200)); err != nil { + t.Fatal(err) + } + if err := s.Drop("u1", "survival", sess.ID); err != nil { + t.Fatal(err) + } + if names := stagedNames(t, s); len(names) != 0 { + t.Fatalf("after Drop: %v", names) + } + if _, err := s.Begin("u2", "survival", "b.zip", 400); err != nil { + t.Fatalf("after Drop: %v", err) + } + if _, err := s.Status("u1", "survival", sess.ID); !errors.Is(err, ErrNotStaged) { + t.Fatalf("Status after Drop: err = %v", err) + } + }) + + t.Run("expiry gives it all back", func(t *testing.T) { + s := diskStage(t, 1000, 1200, 0.5) + now := time.Date(2026, 9, 28, 12, 0, 0, 0, time.UTC) + s.Now = func() time.Time { return now } + sess, err := s.Begin("u1", "survival", "a.zip", 300) + if err != nil { + t.Fatal(err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 0, strings.Repeat("x", 200)); err != nil { + t.Fatal(err) + } + now = now.Add(SessionIdle + time.Nanosecond) + if n := s.Expire(); n != 1 { + t.Fatalf("Expire dropped %d, want 1", n) + } + if _, err := s.Begin("u2", "survival", "b.zip", 400); err != nil { + t.Fatalf("after Expire: %v", err) + } + }) +} + +func TestSessionsPerUserAreBounded(t *testing.T) { + s := diskStage(t, 1000, 1200, 0.5) + var ids []string + for i := range MaxSessionsPerUser { + sess, err := s.Begin("u1", "survival", "a.zip", 10) + if err != nil { + t.Fatalf("session %d: %v", i+1, err) + } + ids = append(ids, sess.ID) + } + if _, err := s.Begin("u1", "creative", "a.zip", 10); !errors.Is(err, ErrTooManySessions) { + t.Fatalf("one more on another server: err = %v, want ErrTooManySessions", err) + } + // The refused Begin kept neither a file nor its reservation: room for 400 + // less the four sessions' 40. + if names := stagedNames(t, s); len(names) != MaxSessionsPerUser { + t.Fatalf("staged %d files, want %d", len(names), MaxSessionsPerUser) + } + if _, err := s.Begin("u2", "survival", "b.zip", 360); err != nil { + t.Fatalf("another user: %v", err) + } + if err := s.Drop("u1", "survival", ids[0]); err != nil { + t.Fatal(err) + } + if _, err := s.Begin("u1", "survival", "a.zip", 10); err != nil { + t.Fatalf("after dropping one: %v", err) + } + // With room reserved, a negative size would wrap the reservation sum round + // to a small number and pass the room check. + if _, err := s.Begin("u3", "survival", "a.zip", -1); err == nil || errors.Is(err, ErrStageFull) { + t.Fatalf("a negative size: err = %v, want it refused for being negative", err) + } +} + +// Expire drops what has sat untouched for longer than SessionIdle, sealed or +// not, and a part keeps a session alive. +func TestSessionExpiry(t *testing.T) { + s := roomyStage(t) + start := time.Date(2026, 9, 28, 12, 0, 0, 0, time.UTC) + now := start + s.Now = func() time.Time { return now } + idle, err := s.Begin("u1", "survival", "idle.zip", 2) + if err != nil { + t.Fatal(err) + } + sealed, err := s.Begin("u1", "survival", "sealed.zip", 1) + if err != nil { + t.Fatal(err) + } + if _, err := appendString(s, "u1", "survival", sealed.ID, 0, "x"); err != nil { + t.Fatal(err) + } + if _, err := s.Seal("u1", "survival", sealed.ID); err != nil { + t.Fatal(err) + } + active, err := s.Begin("u1", "survival", "active.zip", 2) + if err != nil { + t.Fatal(err) + } + + now = start.Add(time.Hour) + if _, err := appendString(s, "u1", "survival", active.ID, 0, "a"); err != nil { + t.Fatal(err) + } + now = start.Add(SessionIdle) + if n := s.Expire(); n != 0 { + t.Fatalf("at exactly SessionIdle Expire dropped %d", n) + } + now = start.Add(SessionIdle + time.Minute) + if n := s.Expire(); n != 2 { + t.Fatalf("Expire dropped %d, want the idle and the sealed one", n) + } + for _, id := range []string{idle.ID, sealed.ID} { + if _, err := s.Status("u1", "survival", id); !errors.Is(err, ErrNotStaged) { + t.Errorf("%s survived Expire: %v", id, err) + } + } + if at, err := s.Status("u1", "survival", active.ID); err != nil || at.Received != 1 { + t.Fatalf("the session a part touched: %+v, %v", at, err) + } + if names := stagedNames(t, s); len(names) != 1 { + t.Fatalf("files left: %v, want the active session's", names) + } + + // A Seal, and the Job's Open, each start the idle clock again: a Job begun + // on an upload that sat for hours still finds it there. + for _, tc := range []struct { + name string + // sealAt and openAt are how long after the last part the Seal and the + // Job's Open come; a negative openAt is no Open. + sealAt, openAt time.Duration + }{ + {"seal", 4 * time.Hour, -1}, + {"open", 0, 4 * time.Hour}, + } { + begun := now + sess, err := s.Begin("u1", "survival", tc.name+".zip", 1) + if err != nil { + t.Fatal(err) + } + if _, err := appendString(s, "u1", "survival", sess.ID, 0, "x"); err != nil { + t.Fatal(err) + } + now = begun.Add(tc.sealAt) + st, err := s.Seal("u1", "survival", sess.ID) + if err != nil { + t.Fatal(err) + } + if tc.openAt >= 0 { + now = begun.Add(tc.openAt) + readStaged(t, s, sess.ID, st.Token) + } + now = begun.Add(SessionIdle + time.Minute) + s.Expire() + if _, err := s.Status("u1", "survival", sess.ID); err != nil { + t.Errorf("%s: a session touched 4h after its last part expired 6h after it: %v", tc.name, err) + } + if err := s.Drop("u1", "survival", sess.ID); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/fileedit/stage.go b/internal/fileedit/stage.go index 13dd11f..8f4fcbe 100644 --- a/internal/fileedit/stage.go +++ b/internal/fileedit/stage.go @@ -12,6 +12,7 @@ import ( "os" "sync" "syscall" + "time" ) // Stage holds uploads between the request that brought them and the Job that @@ -25,7 +26,9 @@ import ( // Job carries in its environment. Only a digest of the token is kept, compared in // constant time, and the first successful Open spends it: the Job never retries, // so a second Open could only be someone else. The release func Put returns -// deletes the file once the Job has answered, whatever it answered. +// deletes the file once the Job has answered, whatever it answered. A file too +// big for one request arrives in parts instead (session.go) and is fetched the +// same way. // // Nothing here outlives the process: the index is in memory, so Sweep empties // Dir at startup of whatever a previous process left behind. @@ -38,8 +41,12 @@ type Stage struct { // the submission store shares. MinFree float64 + // Now is the clock sessions are aged by (time.Now when nil). + Now func() time.Time + mu sync.Mutex items map[string]*stagedFile + sessions map[string]*session reserved int64 } @@ -172,23 +179,27 @@ func stageFailure(readErr, copyErr, closeErr error, n, size int64) error { // newHandle mints the id and token for a staged upload whose bytes h hashed. func newHandle(h hash.Hash, size int64) (Staged, [sha256.Size]byte, error) { - var id [16]byte - var token [32]byte - if _, err := rand.Read(id[:]); err != nil { + id, err := randomHex(16) + if err != nil { return Staged{}, [sha256.Size]byte{}, fmt.Errorf("fileedit: generate an upload id: %w", err) } - if _, err := rand.Read(token[:]); err != nil { + token, err := randomHex(32) + if err != nil { return Staged{}, [sha256.Size]byte{}, fmt.Errorf("fileedit: generate an upload token: %w", err) } - st := Staged{ - ID: hex.EncodeToString(id[:]), - Token: hex.EncodeToString(token[:]), - SHA256: hex.EncodeToString(h.Sum(nil)), - Size: size, - } + st := Staged{ID: id, Token: token, SHA256: hex.EncodeToString(h.Sum(nil)), Size: size} return st, sha256.Sum256([]byte(st.Token)), nil } +// randomHex is n random bytes in hex. +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + // reserve admits an upload of size bytes if the disk keeps MinFree free after it // and after every upload still being written. Those have not reached the disk // yet, so statfs alone would let two of them through on room for one. @@ -219,23 +230,30 @@ func (s *Stage) unreserve(size int64) { s.mu.Unlock() } -// Open spends a staged upload's token and returns its file and size. Any -// mismatch is ErrNotStaged. +// Open spends a staged upload's token, or a sealed session's (Seal), and returns +// its file and size. Any mismatch is ErrNotStaged. func (s *Stage) Open(id, token string) (*os.File, int64, error) { sum := sha256.Sum256([]byte(token)) s.mu.Lock() - it, ok := s.items[id] - if !ok || it.used || subtle.ConstantTimeCompare(sum[:], it.tokenHash[:]) != 1 { - s.mu.Unlock() - return nil, 0, ErrNotStaged + name, size, found, err := s.openSession(id, sum) + if !found { + it, ok := s.items[id] + if !ok || it.used || subtle.ConstantTimeCompare(sum[:], it.tokenHash[:]) != 1 { + err = ErrNotStaged + } else { + it.used = true + name, size = it.path, it.size + } } - it.used = true s.mu.Unlock() - f, err := os.Open(it.path) + if err != nil { + return nil, 0, err + } + f, err := os.Open(name) if err != nil { return nil, 0, fmt.Errorf("fileedit: open the staged upload: %w", err) } - return f, it.size, nil + return f, size, nil } // bodyReader remembers the body's own read error, so Put can tell a client that diff --git a/internal/fileedit/unzip.go b/internal/fileedit/unzip.go new file mode 100644 index 0000000..a1dc0ba --- /dev/null +++ b/internal/fileedit/unzip.go @@ -0,0 +1,536 @@ +package fileedit + +import ( + "archive/zip" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "math" + "os" + "path" + "sort" + "strings" + "unicode/utf8" + + "golang.org/x/text/encoding/simplifiedchinese" +) + +// The codes an unzip refuses an archive with. Each names what is wrong with the +// ARCHIVE, so the panel can say "this zip is broken" rather than "your path is +// wrong"; Result.Entry names the entry. +const ( + // CodeArchiveInvalid is a file that is not a zip, a damaged one, one whose + // entry bytes disagree with their header (size or CRC), or one with nothing in it. + CodeArchiveInvalid = "archive_invalid" + // CodeArchiveUnsafe is an entry naming a path outside the folder it is + // extracted into ("../", "/etc/x", "C:\x"), or a device, pipe or socket. + CodeArchiveUnsafe = "archive_unsafe" + // CodeArchiveSymlink is an entry that is a symbolic link. A link extracted into + // the world could point anywhere, and every later op would have to reason about + // it, so an archive carrying one is refused whole. + CodeArchiveSymlink = "archive_symlink" + // CodeTypeConflict is an archive with a file where the server has a folder, a + // folder where it has a file, or anything where it has a link. Overwrite never + // resolves it: replacing a folder with a file would delete the folder. + CodeTypeConflict = "type_conflict" +) + +// MaxConflicts bounds Result.Conflicts, keeping the result line bounded when an +// archive would replace a whole world; ConflictCount still says how many there +// are. +const MaxConflicts = 200 + +// unzipEntryOverhead is what the space check adds per entry for the inode and +// directory block it takes beyond its bytes. +const unzipEntryOverhead = 4096 + +// unzipTempPrefix names the working folder an unzip extracts into. It sits in +// the destination folder, so every move out of it is a rename on one volume. +const unzipTempPrefix = ".felis-unzip-" + +// unzip extracts the .zip at name into the folder holding it. +// +// It is all or nothing. Every check that can refuse the archive runs before a +// byte is written: entry names, entry types, what is already on the server, and +// the room on the volume. The entries are then extracted into a working folder +// beside the destination, and only once every one of them has been written and +// verified are they renamed into place. A failure at any point before that +// leaves the destination exactly as it was. The renames themselves are journaled +// and undone in reverse if one fails, so a replaced file comes back. Only a Job +// killed in the middle of the renames — a few milliseconds for thousands of +// files — can leave the archive half applied. +// +// An existing file the archive would replace is a conflict: without overwrite the +// unzip lists them (CodeExists, Conflicts) and changes nothing; with it they are +// replaced. Folders merge. A file where the server has a folder, or the reverse, +// is CodeTypeConflict whatever overwrite says. +// +// The only size bound is the volume. Each entry's declared size is summed and +// checked against the free space up front, and archive/zip itself refuses an +// entry whose bytes run past its declared size or fail its CRC, so an archive +// that lies about its sizes (a zip bomb) stops at the first lying entry and +// nothing it wrote survives. +func unzip(r *os.Root, rootPath, name string, overwrite bool, progress func(done, total int64)) Result { + name = path.Clean(name) + if !strings.EqualFold(path.Ext(name), ".zip") { + return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is not a .zip archive", name)} + } + f, err := r.Open(name) + if err != nil { + return failure(err, name) + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return failure(err, name) + } + if !info.Mode().IsRegular() { + return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is not a file", name)} + } + // ErrInsecurePath comes back WITH a usable reader, and only under + // GODEBUG=zipinsecurepath=0; planUnzip does that check itself, for every + // entry, whatever the setting. + zr, err := zip.NewReader(f, info.Size()) + if err != nil && !errors.Is(err, zip.ErrInsecurePath) { + return Result{Code: CodeArchiveInvalid, Error: fmt.Sprintf("%s is not a readable zip archive: %v", name, err)} + } + p, res := planUnzip(zr.File) + if res.Code != "" { + return res + } + + dest := path.Dir(name) + present, replaced, res := checkTargets(r, dest, p) + if res.Code != "" { + return res + } + if len(replaced) > 0 && !overwrite { + list := make([]string, 0, len(replaced)) + for n := range replaced { + list = append(list, path.Join(dest, n)) + } + sort.Strings(list) + count := len(list) + if count > MaxConflicts { + list = list[:MaxConflicts] + } + return Result{Code: CodeExists, Conflicts: list, ConflictCount: count, Error: fmt.Sprintf( + "%d files in the archive already exist on the server; extract again with overwrite to replace them", count)} + } + + // A working folder left by an unzip that was killed holds only a copy, and + // clearing it first gives its room back to the check below. + sweepUnzipTemps(r, dest) + need := p.bytes + int64(len(p.files)+len(p.dirs))*unzipEntryOverhead + if avail, _, err := statfs(rootPath); err == nil && uint64(need) > avail { + free := int64(math.MaxInt64) + if avail < math.MaxInt64 { + free = int64(avail) + } + return Result{Code: CodeNoSpace, Need: need, Avail: free, Error: fmt.Sprintf( + "extracting %s needs %d bytes and the server's volume has %d free; nothing was changed", name, need, free)} + } + + var suffix [6]byte + if _, err := rand.Read(suffix[:]); err != nil { + return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)} + } + tmp := path.Join(dest, unzipTempPrefix+hex.EncodeToString(suffix[:])) + staged, old := path.Join(tmp, "new"), path.Join(tmp, "old") + for _, d := range []string{tmp, staged, old} { + if err := r.Mkdir(d, 0o700); err != nil { + _ = r.RemoveAll(tmp) + return unzipWriteFailure(err, dest) + } + } + // After a success tmp holds only the files the archive replaced; after a + // failure, everything the archive wrote. Either way it goes. + defer func() { _ = r.RemoveAll(tmp) }() + + if res := extractAll(r, staged, p, progress); res.Code != "" { + return res + } + if res := placeAll(r, dest, staged, old, p, present, replaced); res.Code != "" { + return res + } + return Result{Files: len(p.files), Bytes: p.bytes} +} + +// unzipPlan is an archive's entries once every one has passed planUnzip. Names +// are cleaned, slash-separated and relative to the destination folder. +type unzipPlan struct { + files []zipFile + // dirs is every folder the archive makes, named in it or implied by a file + // inside it, sorted so a folder comes before everything in it. + dirs []string + isDir map[string]bool + bytes int64 // the declared size of every file, summed +} + +type zipFile struct { + f *zip.File + name string + mode fs.FileMode +} + +// planUnzip checks every entry's name and type and works out what the archive +// makes. Nothing about the server is consulted yet. +func planUnzip(entries []*zip.File) (unzipPlan, Result) { + p := unzipPlan{isDir: map[string]bool{}} + byName := map[string]bool{} + for _, f := range entries { + raw := entryName(f) + name, ok := cleanEntry(raw) + if !ok { + return p, Result{Code: CodeArchiveUnsafe, Entry: raw, Error: fmt.Sprintf( + "%s leads outside the folder it would be extracted into", raw)} + } + // macOS's Finder adds __MACOSX/ to every zip it makes: resource forks that + // mean nothing on the server. + if name == "__MACOSX" || strings.HasPrefix(name, "__MACOSX/") { + continue + } + mode := f.Mode() + isDir := mode.IsDir() || strings.HasSuffix(raw, "/") + switch { + case mode&fs.ModeSymlink != 0: + return p, Result{Code: CodeArchiveSymlink, Entry: raw, Error: fmt.Sprintf( + "%s is a symbolic link; an archive containing links is not extracted", raw)} + case isDir: + if name != "." { + p.isDir[name] = true + } + continue + case !mode.IsRegular(): + return p, Result{Code: CodeArchiveUnsafe, Entry: raw, Error: fmt.Sprintf( + "%s is not a regular file", raw)} + case name == ".": + return p, Result{Code: CodeArchiveUnsafe, Entry: raw, Error: fmt.Sprintf( + "%s names the destination folder itself", raw)} + case byName[name]: + return p, Result{Code: CodeArchiveInvalid, Entry: raw, Error: fmt.Sprintf( + "%s appears in the archive twice", raw)} + case f.UncompressedSize64 > uint64(math.MaxInt64-p.bytes): + return p, Result{Code: CodeArchiveInvalid, Entry: raw, Error: fmt.Sprintf( + "%s declares an impossible size", raw)} + } + byName[name] = true + p.bytes += int64(f.UncompressedSize64) + // Anything the archive marks executable (a start.sh) stays executable; + // every other permission is the server's usual. + perm := fs.FileMode(0o644) + if mode.Perm()&0o111 != 0 { + perm = 0o755 + } + p.files = append(p.files, zipFile{f: f, name: name, mode: perm}) + } + for _, zf := range p.files { + // cleanEntry already refused a rooted name; stopping at "/" as well keeps + // this loop finite should that check ever move. + for d := path.Dir(zf.name); d != "." && d != "/"; d = path.Dir(d) { + p.isDir[d] = true + } + } + for _, zf := range p.files { + if p.isDir[zf.name] { + return p, Result{Code: CodeArchiveInvalid, Entry: zf.name, Error: fmt.Sprintf( + "%s is both a file and a folder in the archive", zf.name)} + } + } + if len(p.files) == 0 && len(p.isDir) == 0 { + return p, Result{Code: CodeArchiveInvalid, Error: "the archive has nothing to extract"} + } + for d := range p.isDir { + p.dirs = append(p.dirs, d) + } + // A folder's name is a prefix of everything in it, and a prefix sorts first. + sort.Strings(p.dirs) + sort.Slice(p.files, func(i, j int) bool { return p.files[i].name < p.files[j].name }) + return p, Result{} +} + +// entryName is an entry's name as its maker meant it. A zip made on Chinese +// Windows stores names in the system code page (GBK) without the UTF-8 flag; a +// name that is not valid UTF-8 is decoded as GB18030, GBK's superset. The flag +// alone is no guide: archive/zip reports NonUTF8 for every name made without it, +// which includes plain ASCII and macOS's UTF-8. Decoding comes before the +// backslash below because a GBK trail byte can be 0x5C. +func entryName(f *zip.File) string { + name := f.Name + if !utf8.ValidString(name) { + if s, err := simplifiedchinese.GB18030.NewDecoder().String(name); err == nil { + name = s + } + } + return strings.ReplaceAll(name, `\`, "/") +} + +// cleanEntry cleans an entry name and reports false for one that must not be +// extracted: a NUL, an absolute path, a drive letter, or a climb out of the +// destination. It judges the name as TEXT, which is sound here because nothing +// is resolved through it until checkTargets and extraction, and those go through +// os.Root, which would refuse an escape anyway. +func cleanEntry(raw string) (string, bool) { + if raw == "" || strings.ContainsRune(raw, 0) || strings.HasPrefix(raw, "/") { + return "", false + } + if len(raw) >= 2 && raw[1] == ':' && (raw[0]|0x20) >= 'a' && (raw[0]|0x20) <= 'z' { + return "", false + } + name := path.Clean(raw) + if name == ".." || strings.HasPrefix(name, "../") { + return "", false + } + return name, true +} + +// checkTargets looks at what the server already has where the archive lands. +// present is the archive's folders that exist on the server (they merge); +// replaced is its files that do (conflicts). A folder the server lacks cannot +// hold anything, so its contents are not looked up. +func checkTargets(r *os.Root, dest string, p unzipPlan) (present, replaced map[string]bool, res Result) { + present, replaced = map[string]bool{}, map[string]bool{} + absent := func(name string) bool { + parent := path.Dir(name) + return parent != "." && !present[parent] + } + for _, d := range p.dirs { + if absent(d) { + continue + } + at := path.Join(dest, d) + info, err := r.Lstat(at) + switch { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return nil, nil, failure(err, at) + case info.Mode()&fs.ModeSymlink != 0: + return nil, nil, typeConflict(at, "is a link on the server, and the archive has a folder there") + case info.IsDir(): + present[d] = true + default: + return nil, nil, typeConflict(at, "is a file on the server, and the archive has a folder there") + } + } + for _, zf := range p.files { + if absent(zf.name) { + continue + } + at := path.Join(dest, zf.name) + info, err := r.Lstat(at) + switch { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return nil, nil, failure(err, at) + case info.Mode().IsRegular(): + replaced[zf.name] = true + case info.IsDir(): + return nil, nil, typeConflict(at, "is a folder on the server, and the archive has a file there") + default: + return nil, nil, typeConflict(at, "is a link or special file on the server, and the archive has a file there") + } + } + return present, replaced, Result{} +} + +func typeConflict(at, why string) Result { + return Result{Code: CodeTypeConflict, Entry: at, Error: fmt.Sprintf("%s %s; nothing was changed", at, why)} +} + +// extractAll writes every folder and file of p under staged, handed to the game +// uid, and syncs each file. +func extractAll(r *os.Root, staged string, p unzipPlan, progress func(done, total int64)) Result { + for _, d := range p.dirs { + at := path.Join(staged, d) + if err := r.Mkdir(at, 0o755); err != nil { + return unzipWriteFailure(err, d) + } + _ = ownWritten(r, at) + } + var done int64 + count := func(n int) { + done += int64(n) + if progress != nil { + progress(done, p.bytes) + } + } + for _, zf := range p.files { + if res := extractOne(r, path.Join(staged, zf.name), zf, count); res.Code != "" { + return res + } + } + for _, d := range p.dirs { + syncDir(r, path.Join(staged, d)) + } + syncDir(r, staged) + return Result{} +} + +func extractOne(r *os.Root, at string, zf zipFile, count func(int)) Result { + invalid := func(err error) Result { + return Result{Code: CodeArchiveInvalid, Entry: zf.name, Error: fmt.Sprintf( + "%s in the archive is damaged: %v; nothing was changed", zf.name, err)} + } + src, err := zf.f.Open() + if err != nil { + return invalid(err) + } + defer src.Close() + w, err := r.OpenFile(at, os.O_WRONLY|os.O_CREATE|os.O_EXCL, zf.mode) + if err != nil { + return unzipWriteFailure(err, zf.name) + } + if err := w.Chmod(zf.mode); err != nil { + w.Close() + return unzipWriteFailure(err, zf.name) + } + if _, err := io.Copy(countingWriter{w, count}, archiveReader{src}); err != nil { + w.Close() + var ae *archiveError + if errors.As(err, &ae) { + return invalid(ae.err) + } + return unzipWriteFailure(err, zf.name) + } + if err := syncWritten(w); err != nil { + w.Close() + return unzipWriteFailure(err, zf.name) + } + if err := w.Close(); err != nil { + return unzipWriteFailure(err, zf.name) + } + _ = ownWritten(r, at) + return Result{} +} + +// placeAll renames the extracted tree into dest. A folder the server lacks moves +// whole; one it has is descended into. A file it has is first moved aside into +// old, so undoing the journal puts it back. +func placeAll(r *os.Root, dest, staged, old string, p unzipPlan, present, replaced map[string]bool) Result { + kids := map[string][]string{} + for _, d := range p.dirs { + kids[path.Dir(d)] = append(kids[path.Dir(d)], d) + } + for _, zf := range p.files { + kids[path.Dir(zf.name)] = append(kids[path.Dir(zf.name)], zf.name) + } + + type move struct{ from, to string } + var journal []move + mv := func(from, to string) error { + if err := renameEntry(r, from, to); err != nil { + return err + } + journal = append(journal, move{from, to}) + return nil + } + var place func(dir string) error + place = func(dir string) error { + for _, c := range kids[dir] { + src, dst := path.Join(staged, c), path.Join(dest, c) + switch { + case p.isDir[c] && present[c]: + if err := place(c); err != nil { + return err + } + case replaced[c]: + aside := path.Join(old, c) + if err := r.MkdirAll(path.Dir(aside), 0o700); err != nil { + return err + } + if err := mv(dst, aside); err != nil { + return err + } + if err := mv(src, dst); err != nil { + return err + } + default: + if err := mv(src, dst); err != nil { + return err + } + } + } + return nil + } + if err := place("."); err != nil { + for i := len(journal) - 1; i >= 0; i-- { + _ = r.Rename(journal[i].to, journal[i].from) + } + return unzipWriteFailure(err, dest) + } + syncDir(r, dest) + for d := range present { + syncDir(r, path.Join(dest, d)) + } + return Result{} +} + +// renameEntry is the rename placeAll moves with. A var so a test can fail one +// part-way through and watch the journal undo the rest. +var renameEntry = func(r *os.Root, from, to string) error { return r.Rename(from, to) } + +// sweepUnzipTemps removes working folders a killed unzip left in dir. +func sweepUnzipTemps(r *os.Root, dir string) { + d, err := r.Open(dir) + if err != nil { + return + } + names, _ := d.Readdirnames(-1) + d.Close() + for _, n := range names { + if isUnzipTemp(n) { + _ = r.RemoveAll(path.Join(dir, n)) + } + } +} + +func isUnzipTemp(name string) bool { + suffix, ok := strings.CutPrefix(name, unzipTempPrefix) + if !ok || len(suffix) != 12 { + return false + } + _, err := hex.DecodeString(suffix) + return err == nil +} + +// unzipWriteFailure is writeFailure worded for an unzip, which by then has +// changed nothing whatever the step that failed. +func unzipWriteFailure(err error, name string) Result { + res := writeFailure(err, name) + if res.Code == CodeNoSpace { + res.Error = "the server's volume filled up while extracting; nothing was changed" + } + return res +} + +// archiveError marks a failure reading an entry's bytes out of the archive, so +// extractOne can tell a damaged archive from the volume failing underneath. +type archiveError struct{ err error } + +func (e *archiveError) Error() string { return "read archive: " + e.err.Error() } +func (e *archiveError) Unwrap() error { return e.err } + +type archiveReader struct{ r io.Reader } + +func (a archiveReader) Read(p []byte) (int, error) { + n, err := a.r.Read(p) + if err != nil && err != io.EOF { + err = &archiveError{err} + } + return n, err +} + +// countingWriter reports each write's length to add. +type countingWriter struct { + w io.Writer + add func(int) +} + +func (c countingWriter) Write(p []byte) (int, error) { + n, err := c.w.Write(p) + c.add(n) + return n, err +} diff --git a/internal/fileedit/unzip_test.go b/internal/fileedit/unzip_test.go new file mode 100644 index 0000000..5599cb1 --- /dev/null +++ b/internal/fileedit/unzip_test.go @@ -0,0 +1,539 @@ +package fileedit + +import ( + "archive/zip" + "bytes" + "errors" + "fmt" + "hash/crc32" + "io" + "io/fs" + "os" + "path/filepath" + "reflect" + "strings" + "syscall" + "testing" + + "golang.org/x/text/encoding/simplifiedchinese" +) + +// zent is one entry of a test archive: a deflated file, or a folder when the name +// ends in "/". +type zent struct { + name string + body string + mode fs.FileMode // set on the header when non-zero + flat bool // written without the UTF-8 flag, as old Windows tools do + // raw writes body stored as-is under a header declaring size and crc, so a + // test can build an archive whose entries lie about themselves. + raw bool + size uint64 + crc uint32 +} + +func file(name, body string) zent { return zent{name: name, body: body} } + +// lie is an entry declaring size bytes while holding body. +func lie(name, body string, size uint64) zent { + return zent{name: name, body: body, raw: true, size: size, crc: crc32.ChecksumIEEE([]byte(body))} +} + +func writeZip(t *testing.T, at string, entries ...zent) { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for _, e := range entries { + fh := &zip.FileHeader{Name: e.name, Method: zip.Deflate, NonUTF8: e.flat} + if e.mode != 0 { + fh.SetMode(e.mode) + } + var dst io.Writer + var err error + if e.raw { + fh.Method = zip.Store + fh.CRC32, fh.CompressedSize64, fh.UncompressedSize64 = e.crc, uint64(len(e.body)), e.size + dst, err = w.CreateRaw(fh) + } else { + dst, err = w.CreateHeader(fh) + } + if err != nil { + t.Fatalf("zip entry %q: %v", e.name, err) + } + if _, err := io.WriteString(dst, e.body); err != nil { + t.Fatalf("zip entry %q: %v", e.name, err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(at, buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } +} + +// tree is everything under dir: each file's content, "" for a folder and +// "-> target" for a link. Comparing two trees is how a test says "nothing +// changed", working folders included. +func tree(t *testing.T, dir string) map[string]string { + t.Helper() + out := map[string]string{} + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(dir, p) + switch { + case rel == ".": + case d.Type()&fs.ModeSymlink != 0: + target, err := os.Readlink(p) + if err != nil { + return err + } + out[rel] = "-> " + target + case d.IsDir(): + out[rel] = "" + default: + b, err := os.ReadFile(p) + if err != nil { + return err + } + out[rel] = string(b) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return out +} + +func assertSameTree(t *testing.T, before, after map[string]string) { + t.Helper() + if !reflect.DeepEqual(before, after) { + t.Fatalf("the tree changed:\nbefore %v\nafter %v", before, after) + } +} + +func unzipAt(t *testing.T, root, name string, overwrite bool) Result { + t.Helper() + return exec(t, root, Request{Op: OpUnzip, Path: name, Overwrite: overwrite}) +} + +func TestUnzip(t *testing.T) { + t.Run("extracts files and folders into the folder holding the archive", func(t *testing.T) { + root, _ := worldRoot(t) + if err := os.Mkdir(filepath.Join(root, "plugins"), 0o755); err != nil { + t.Fatal(err) + } + writeZip(t, filepath.Join(root, "plugins", "pack.zip"), + zent{name: "Essentials/"}, + file("Essentials/config.yml", "locale: zh\n"), + zent{name: "empty/"}, + file("./readme.txt", "hi"), + file(`win\sub\a.txt`, "from windows"), + ) + var owned []string + prev := ownWritten + ownWritten = func(_ *os.Root, name string) error { owned = append(owned, name); return nil } + defer func() { ownWritten = prev }() + + res := unzipAt(t, root, "plugins/pack.zip", false) + if res.Code != "" || res.Files != 3 || res.Bytes != int64(len("locale: zh\n")+len("hi")+len("from windows")) { + t.Fatalf("result = %+v", res) + } + got := tree(t, filepath.Join(root, "plugins")) + delete(got, "pack.zip") + want := map[string]string{ + "Essentials": "", "Essentials/config.yml": "locale: zh\n", "empty": "", + "readme.txt": "hi", "win": "", "win/sub": "", "win/sub/a.txt": "from windows", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("plugins/ = %v\nwant %v", got, want) + } + if info, _ := os.Stat(filepath.Join(root, "plugins", "readme.txt")); info.Mode().Perm() != 0o644 { + t.Fatalf("mode = %v, want 0644", info.Mode().Perm()) + } + // Every folder and file is handed to the game uid, while still in the + // working folder: 4 folders and 3 files. + if len(owned) != 7 { + t.Fatalf("handed to the game uid: %v, want 7", owned) + } + for _, o := range owned { + if !strings.HasPrefix(o, "plugins/"+unzipTempPrefix) { + t.Fatalf("%s was chowned outside the working folder", o) + } + } + }) + + t.Run("an archive named in capitals extracts", func(t *testing.T) { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "PACK.ZIP"), file("a.txt", "a")) + if res := unzipAt(t, root, "PACK.ZIP", false); res.Code != "" || res.Files != 1 { + t.Fatalf("result = %+v", res) + } + if got := mustRead(t, filepath.Join(root, "a.txt")); got != "a" { + t.Fatalf("a.txt = %q", got) + } + }) + + t.Run("__MACOSX is left out", func(t *testing.T) { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "mac.zip"), file("a.txt", "a"), zent{name: "__MACOSX/"}, file("__MACOSX/._a.txt", "fork")) + if res := unzipAt(t, root, "mac.zip", false); res.Code != "" || res.Files != 1 { + t.Fatalf("result = %+v", res) + } + assertAbsent(t, filepath.Join(root, "__MACOSX")) + }) + + t.Run("a name made on Chinese Windows is decoded from GBK; UTF-8 without the flag is kept", func(t *testing.T) { + root, _ := worldRoot(t) + gbk, err := simplifiedchinese.GBK.NewEncoder().String("存档/说明.txt") + if err != nil { + t.Fatal(err) + } + writeZip(t, filepath.Join(root, "cn.zip"), + zent{name: gbk, body: "中文", flat: true}, + zent{name: "macOS名字.txt", body: "utf8", flat: true}, + ) + if res := unzipAt(t, root, "cn.zip", false); res.Code != "" || res.Files != 2 { + t.Fatalf("result = %+v", res) + } + if got := mustRead(t, filepath.Join(root, "存档", "说明.txt")); got != "中文" { + t.Fatalf("存档/说明.txt = %q", got) + } + if got := mustRead(t, filepath.Join(root, "macOS名字.txt")); got != "utf8" { + t.Fatalf("macOS名字.txt = %q", got) + } + }) + + t.Run("an executable entry stays executable; every other file is 0644", func(t *testing.T) { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "sh.zip"), + zent{name: "start.sh", body: "#!/bin/sh\n", mode: 0o755}, + zent{name: "secret.txt", body: "x", mode: 0o600}, + ) + if res := unzipAt(t, root, "sh.zip", false); res.Code != "" { + t.Fatalf("result = %+v", res) + } + for name, want := range map[string]fs.FileMode{"start.sh": 0o755, "secret.txt": 0o644} { + if info, _ := os.Stat(filepath.Join(root, name)); info.Mode().Perm() != want { + t.Errorf("%s mode = %v, want %v", name, info.Mode().Perm(), want) + } + } + }) + + t.Run("a name leading outside is archive_unsafe and nothing is written", func(t *testing.T) { + for raw, entry := range map[string]string{ + "../evil.txt": "../evil.txt", + "a/../../evil.txt": "a/../../evil.txt", + `..\evil.txt`: "../evil.txt", + "/evil.txt": "/evil.txt", + "C:/evil.txt": "C:/evil.txt", + `c:\evil.txt`: "c:/evil.txt", + "nul\x00.txt": "nul\x00.txt", + "a/..": "a/..", + } { + t.Run(entry, func(t *testing.T) { + root, outside := worldRoot(t) + writeZip(t, filepath.Join(root, "bad.zip"), file("ok.txt", "ok"), file(raw, "evil")) + before := tree(t, root) + res := unzipAt(t, root, "bad.zip", true) + if res.Code != CodeArchiveUnsafe || res.Entry != entry { + t.Fatalf("result = %+v; want archive_unsafe naming %q", res, entry) + } + assertSameTree(t, before, tree(t, root)) + assertAbsent(t, filepath.Join(outside, "evil.txt")) + }) + } + }) + + t.Run("a link entry is archive_symlink and a pipe is archive_unsafe", func(t *testing.T) { + for _, tc := range []struct { + mode fs.FileMode + code string + }{ + {fs.ModeSymlink | 0o777, CodeArchiveSymlink}, + {fs.ModeNamedPipe | 0o644, CodeArchiveUnsafe}, + } { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "odd.zip"), + file("ok.txt", "ok"), zent{name: "odd", body: "../../outside/secret.txt", mode: tc.mode}) + before := tree(t, root) + res := unzipAt(t, root, "odd.zip", true) + if res.Code != tc.code || res.Entry != "odd" { + t.Fatalf("%v: result = %+v; want %s naming odd", tc.mode, res, tc.code) + } + assertSameTree(t, before, tree(t, root)) + } + }) + + t.Run("a damaged or senseless archive is archive_invalid and nothing is written", func(t *testing.T) { + for name, tc := range map[string]struct { + entries []zent + entry string + }{ + "bytes past the declared size": {[]zent{file("ok.txt", "ok"), lie("lie.txt", "0123456789", 3)}, "lie.txt"}, + "bytes short of the declared size": {[]zent{file("ok.txt", "ok"), lie("lie.txt", "0123456789", 20)}, "lie.txt"}, + "a wrong checksum": {[]zent{file("ok.txt", "ok"), + {name: "crc.txt", body: "0123456789", raw: true, size: 10, crc: crc32.ChecksumIEEE([]byte("0123456789")) + 1}}, "crc.txt"}, + "the same file twice": {[]zent{file("a.txt", "1"), file("a.txt", "2")}, "a.txt"}, + "a file and a folder at once": {[]zent{file("a", "1"), file("a/b.txt", "2")}, "a"}, + "nothing in it": {nil, ""}, + "nothing but __MACOSX": {[]zent{file("__MACOSX/._a", "fork")}, ""}, + } { + t.Run(name, func(t *testing.T) { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "bad.zip"), tc.entries...) + before := tree(t, root) + res := unzipAt(t, root, "bad.zip", true) + if res.Code != CodeArchiveInvalid || res.Entry != tc.entry { + t.Fatalf("result = %+v; want archive_invalid naming %q", res, tc.entry) + } + assertSameTree(t, before, tree(t, root)) + }) + } + t.Run("not a zip at all", func(t *testing.T) { + root, _ := worldRoot(t) + if err := os.WriteFile(filepath.Join(root, "fake.zip"), []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + if res := unzipAt(t, root, "fake.zip", false); res.Code != CodeArchiveInvalid { + t.Fatalf("result = %+v; want archive_invalid", res) + } + }) + }) + + t.Run("the archive's own path is checked", func(t *testing.T) { + root, outside := worldRoot(t) + if err := os.Mkdir(filepath.Join(root, "dir.zip"), 0o755); err != nil { + t.Fatal(err) + } + writeZip(t, filepath.Join(outside, "x.zip"), file("evil.txt", "evil")) + symlink(t, outside, filepath.Join(root, "escape-link")) + for name, code := range map[string]string{ + "server.properties": CodeBadPath, + "missing.zip": CodeNotFound, + "dir.zip": CodeBadPath, + "../outside/x.zip": CodeBadPath, + "escape-link/x.zip": CodeBadPath, + "config/../../x.zip": CodeBadPath, + } { + if res := unzipAt(t, root, name, true); res.Code != code { + t.Errorf("%s: result = %+v; want %s", name, res, code) + } + } + assertAbsent(t, filepath.Join(root, "evil.txt")) + assertAbsent(t, filepath.Join(outside, "evil.txt")) + }) + + t.Run("files already there are listed and nothing changes without overwrite", func(t *testing.T) { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "pack.zip"), + file("server.properties", "motd=new\n"), file("config/paper.yml", "verbose: true\n"), + file("config/new.yml", "new"), file("fresh/x.txt", "x")) + before := tree(t, root) + res := unzipAt(t, root, "pack.zip", false) + if res.Code != CodeExists || res.ConflictCount != 2 || + !reflect.DeepEqual(res.Conflicts, []string{"config/paper.yml", "server.properties"}) { + t.Fatalf("result = %+v; want exists listing config/paper.yml and server.properties", res) + } + assertSameTree(t, before, tree(t, root)) + }) + + t.Run("conflicts are named from the server's root when the archive sits in a folder", func(t *testing.T) { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "config", "pack.zip"), file("paper.yml", "verbose: true\n")) + res := unzipAt(t, root, "config/pack.zip", false) + if res.Code != CodeExists || !reflect.DeepEqual(res.Conflicts, []string{"config/paper.yml"}) { + t.Fatalf("result = %+v", res) + } + }) + + t.Run("the list stops at MaxConflicts and the count does not", func(t *testing.T) { + root, _ := worldRoot(t) + var entries []zent + for i := range MaxConflicts + 1 { + name := fmt.Sprintf("f%03d.txt", i) + if err := os.WriteFile(filepath.Join(root, name), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + entries = append(entries, file(name, "new")) + } + writeZip(t, filepath.Join(root, "many.zip"), entries...) + res := unzipAt(t, root, "many.zip", false) + if res.Code != CodeExists || res.ConflictCount != MaxConflicts+1 || len(res.Conflicts) != MaxConflicts || + res.Conflicts[0] != "f000.txt" || res.Conflicts[MaxConflicts-1] != "f199.txt" { + t.Fatalf("code %q, count %d, %d listed (%v … %v)", res.Code, res.ConflictCount, len(res.Conflicts), + res.Conflicts[:1], res.Conflicts[len(res.Conflicts)-1:]) + } + }) + + t.Run("overwrite replaces files, merges folders and keeps everything else", func(t *testing.T) { + root, _ := worldRoot(t) + if err := os.WriteFile(filepath.Join(root, "config", "keep.yml"), []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + writeZip(t, filepath.Join(root, "pack.zip"), + file("server.properties", "motd=new\n"), file("config/paper.yml", "verbose: true\n"), + file("config/new.yml", "new"), file("fresh/x.txt", "x")) + res := unzipAt(t, root, "pack.zip", true) + if res.Code != "" || res.Files != 4 { + t.Fatalf("result = %+v", res) + } + got := tree(t, root) + delete(got, "pack.zip") + want := map[string]string{ + "server.properties": "motd=new\n", "config": "", "config/paper.yml": "verbose: true\n", + "config/keep.yml": "keep", "config/new.yml": "new", "fresh": "", "fresh/x.txt": "x", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("world = %v\nwant %v", got, want) + } + }) + + t.Run("a file where the server has a folder, the reverse, or a link is type_conflict even with overwrite", func(t *testing.T) { + for name, tc := range map[string]struct { + entry string + want string + }{ + "folder over a file": {"server.properties/x.txt", "server.properties"}, + "file over a folder": {"config", "config"}, + "folder over a link": {"escape-link/evil.txt", "escape-link"}, + "file over a link": {"planted.txt", "planted.txt"}, + } { + t.Run(name, func(t *testing.T) { + root, outside := worldRoot(t) + symlink(t, outside, filepath.Join(root, "escape-link")) + symlink(t, "server.properties", filepath.Join(root, "planted.txt")) + writeZip(t, filepath.Join(root, "pack.zip"), file("ok.txt", "ok"), file(tc.entry, "evil")) + before := tree(t, root) + res := unzipAt(t, root, "pack.zip", true) + if res.Code != CodeTypeConflict || res.Entry != tc.want { + t.Fatalf("result = %+v; want type_conflict naming %s", res, tc.want) + } + assertSameTree(t, before, tree(t, root)) + assertAbsent(t, filepath.Join(outside, "evil.txt")) + }) + } + }) + + t.Run("more than the volume has free is no_space before anything is written", func(t *testing.T) { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "pack.zip"), file("d/a.txt", "0123456789"), file("d/b.txt", "0123456789")) + need := int64(20 + 3*unzipEntryOverhead) // two files and the folder d + stubStatfs(t, uint64(need-1), 1<<30) + before := tree(t, root) + res := unzipAt(t, root, "pack.zip", false) + if res.Code != CodeNoSpace || res.Need != need || res.Avail != need-1 { + t.Fatalf("result = %+v; want no_space with need %d, avail %d", res, need, need-1) + } + assertSameTree(t, before, tree(t, root)) + stubStatfs(t, uint64(need), 1<<30) + if res := unzipAt(t, root, "pack.zip", false); res.Code != "" { + t.Fatalf("with exactly enough room: %+v", res) + } + }) + + t.Run("the volume filling up mid-way is no_space and changes nothing", func(t *testing.T) { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "pack.zip"), file("a.txt", "a"), file("server.properties", "motd=new\n")) + calls := 0 + prev := syncWritten + syncWritten = func(f *os.File) error { + if calls++; calls == 2 { + return syscall.ENOSPC + } + return f.Sync() + } + defer func() { syncWritten = prev }() + before := tree(t, root) + if res := unzipAt(t, root, "pack.zip", true); res.Code != CodeNoSpace { + t.Fatalf("result = %+v; want no_space", res) + } + assertSameTree(t, before, tree(t, root)) + }) + + // The renames are the one step that touches the server's files; one failing + // part-way must put back every file already moved, replaced ones included. + t.Run("a rename failing part-way is undone, whichever it is", func(t *testing.T) { + // config present: paper.yml aside + in, z.yml in; zeta in; server.properties aside + in. + const moves = 6 + for failAt := 1; failAt <= moves; failAt++ { + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "pack.zip"), + file("config/paper.yml", "verbose: true\n"), file("config/z.yml", "z"), + file("server.properties", "motd=new\n"), file("zeta/x.txt", "x")) + calls := 0 + prev := renameEntry + renameEntry = func(r *os.Root, from, to string) error { + if calls++; calls == failAt { + return errors.New("injected rename failure") + } + return r.Rename(from, to) + } + before := tree(t, root) + res := unzipAt(t, root, "pack.zip", true) + renameEntry = prev + if res.Code == "" { + t.Fatalf("rename %d failing: result = %+v; want a failure", failAt, res) + } + assertSameTree(t, before, tree(t, root)) + } + // And with none failing, the count above is the real number of moves. + root, _ := worldRoot(t) + writeZip(t, filepath.Join(root, "pack.zip"), + file("config/paper.yml", "verbose: true\n"), file("config/z.yml", "z"), + file("server.properties", "motd=new\n"), file("zeta/x.txt", "x")) + calls := 0 + prev := renameEntry + renameEntry = func(r *os.Root, from, to string) error { calls++; return r.Rename(from, to) } + defer func() { renameEntry = prev }() + if res := unzipAt(t, root, "pack.zip", true); res.Code != "" || calls != moves { + t.Fatalf("result = %+v, %d moves; want success in %d", res, calls, moves) + } + }) + + t.Run("a working folder a killed unzip left is cleared; a lookalike is kept", func(t *testing.T) { + root, _ := worldRoot(t) + stale := filepath.Join(root, unzipTempPrefix+"0123456789ab") + if err := os.MkdirAll(filepath.Join(stale, "new"), 0o700); err != nil { + t.Fatal(err) + } + // Twelve characters that are not hex, and hex a byte short or long. + lookalikes := []string{"notahexname!", "0123456789", "0123456789abcd"} + for _, l := range lookalikes { + if err := os.Mkdir(filepath.Join(root, unzipTempPrefix+l), 0o755); err != nil { + t.Fatal(err) + } + } + writeZip(t, filepath.Join(root, "pack.zip"), file("a.txt", "a")) + if res := unzipAt(t, root, "pack.zip", false); res.Code != "" { + t.Fatalf("result = %+v", res) + } + assertAbsent(t, stale) + for _, l := range lookalikes { + if _, err := os.Stat(filepath.Join(root, unzipTempPrefix+l)); err != nil { + t.Fatalf("lookalike %q removed: %v", l, err) + } + } + }) + + t.Run("progress climbs to the total", func(t *testing.T) { + root, _ := worldRoot(t) + big := strings.Repeat("x", 100<<10) + writeZip(t, filepath.Join(root, "pack.zip"), file("a.bin", big), file("b.bin", big)) + var seen []int64 + var total int64 + res := exec(t, root, Request{Op: OpUnzip, Path: "pack.zip", + Progress: func(done, all int64) { seen = append(seen, done); total = all }}) + if res.Code != "" || total != int64(2*len(big)) || len(seen) < 2 || seen[len(seen)-1] != total { + t.Fatalf("result = %+v; progress %d calls ending at %v of %d", res, len(seen), seen[len(seen)-1:], total) + } + for i := 1; i < len(seen); i++ { + if seen[i] < seen[i-1] { + t.Fatalf("progress went backwards: %v", seen) + } + } + }) +} diff --git a/internal/maintenance/maintenance.go b/internal/maintenance/maintenance.go index 4fb96f5..396c9aa 100644 --- a/internal/maintenance/maintenance.go +++ b/internal/maintenance/maintenance.go @@ -62,8 +62,9 @@ const ( // rename, upload) a files Job performs. Every one but list and read holds the // volume. LabelFilesMode = "felis.lolicon.best/files-mode" - // LabelExportMode is what an export Job archives: ExportModeWorld (the live - // world, which holds the volume) or ExportModeBackup (a stored archive). + // LabelExportMode is what an export Job sends: ExportModeWorld (the live + // world) or ExportModeFiles (one file or folder of it), which hold the + // volume, or ExportModeBackup (a stored archive), which does not. LabelExportMode = "felis.lolicon.best/export-mode" // LabelThenRestore marks a backup Job that is the safety snapshot in front of @@ -110,6 +111,7 @@ const ( const ( ExportModeWorld = "world" ExportModeBackup = "backup" + ExportModeFiles = "files" ) // JobKind names the holder a Job represents, or reports false for a Job that diff --git a/internal/maintenance/maintenance_test.go b/internal/maintenance/maintenance_test.go index d7ba147..a905b62 100644 --- a/internal/maintenance/maintenance_test.go +++ b/internal/maintenance/maintenance_test.go @@ -73,7 +73,7 @@ func exportJob(t *testing.T, server, mode string) batchv1.Job { Server: server, ID: "0011223344556677", Mode: mode, WorldPVC: "world-" + server + "-0", BackupPVC: "felis-backups", BackupRef: "/backups/a.tar.gz", TargetURL: "http://api/x", Token: "t", Namespace: "minecraft", ServiceAccount: "felis-restore", Image: "felis:1", - BackupRoot: "/backups", WorldsRoot: "/world", + BackupRoot: "/backups", WorldsRoot: "/world", Path: "plugins", }) if err != nil { t.Fatalf("ExportJob: %v", err) @@ -106,6 +106,7 @@ func TestJobKindMatchesTheExecutors(t *testing.T) { {"file list", filesJob(t, "survival", fileedit.OpList), "", false}, {"world export", exportJob(t, "survival", worldexport.ModeWorld), KindExport, true}, {"backup export", exportJob(t, "survival", worldexport.ModeBackup), "", false}, + {"files export", exportJob(t, "survival", worldexport.ModeFiles), KindExport, true}, } { kind, ok := JobKind(&tc.job) if kind != tc.kind || ok != tc.ok { diff --git a/internal/worldexport/jobspec.go b/internal/worldexport/jobspec.go index cfbd970..8ebb2ec 100644 --- a/internal/worldexport/jobspec.go +++ b/internal/worldexport/jobspec.go @@ -17,8 +17,8 @@ const ( LabelManagedBy = "app.kubernetes.io/managed-by" LabelComponent = "app.kubernetes.io/component" LabelServer = "felis.lolicon.best/server" - // LabelMode is what the Job archives (ModeWorld or ModeBackup). A world - // export holds the world volume; a backup export does not. + // LabelMode is what the Job sends (ModeWorld, ModeBackup or ModeFiles). A + // world or files export holds the world volume; a backup export does not. LabelMode = "felis.lolicon.best/export-mode" managedByValue = "felis-export" @@ -30,10 +30,12 @@ const ( felisBinaryPath = "/usr/local/bin/felis" ) -// The two things an export can archive. +// What an export sends: the whole world as a tar.gz, one stored backup as a +// tar.gz, or one file or folder of the world (a folder as a zip). const ( ModeWorld = "world" ModeBackup = "backup" + ModeFiles = "files" ) // TokenEnv carries the one-time upload token into the Pod. It is the only @@ -49,10 +51,19 @@ type JobParams struct { // upload path, so two exports of one server never collide. ID string Mode string - // WorldPVC is mounted for ModeWorld, BackupPVC and BackupRef for ModeBackup. + // WorldPVC is mounted for ModeWorld and ModeFiles, BackupPVC and BackupRef + // for ModeBackup. WorldPVC string BackupPVC string BackupRef string + // BackupSHA256 is what the stored archive must hash to. The Job checks it + // itself, because what it sends is the archive re-written without its + // secrets and no longer hashes to anything felis-api knows. + BackupSHA256 string + // Path is the file or folder a ModeFiles export sends, and Dir whether the + // caller saw a folder there. + Path string + Dir bool // TargetURL is where the Pod PUTs the archive (felis-api's internal face), // and Token the one-time bearer token that opens it. TargetURL string @@ -118,11 +129,20 @@ func ExportJob(p JobParams) (*batchv1.Job, error) { mount corev1.VolumeMount ) switch p.Mode { - case ModeWorld: + case ModeWorld, ModeFiles: if p.WorldPVC == "" { return nil, fmt.Errorf("worldexport: world PVC name is required") } args = append(args, "--worlds-root", p.WorldsRoot) + if p.Mode == ModeFiles { + if p.Path == "" { + return nil, fmt.Errorf("worldexport: a files export needs a path") + } + args = append(args, "--path", p.Path) + if p.Dir { + args = append(args, "--dir") + } + } volume = readOnlyClaim(worldVolume, p.WorldPVC) mount = corev1.VolumeMount{Name: worldVolume, MountPath: p.WorldsRoot, ReadOnly: true} case ModeBackup: @@ -130,6 +150,9 @@ func ExportJob(p JobParams) (*batchv1.Job, error) { return nil, fmt.Errorf("worldexport: backup PVC name and archive ref are required") } args = append(args, "--ref", p.BackupRef, "--backup-root", p.BackupRoot) + if p.BackupSHA256 != "" { + args = append(args, "--sha256", p.BackupSHA256) + } volume = readOnlyClaim(backupVolume, p.BackupPVC) mount = corev1.VolumeMount{Name: backupVolume, MountPath: p.BackupRoot, ReadOnly: true} default: diff --git a/internal/worldexport/jobspec_test.go b/internal/worldexport/jobspec_test.go index e671ddc..a8e9f4e 100644 --- a/internal/worldexport/jobspec_test.go +++ b/internal/worldexport/jobspec_test.go @@ -18,7 +18,8 @@ func params(mode string) JobParams { return JobParams{ Server: "survival", ID: "0011223344556677", Mode: mode, WorldPVC: "world-survival-0", BackupPVC: "felis-backups", - BackupRef: "/backups/survival-1.tar.gz", + BackupRef: "/backups/survival-1.tar.gz", BackupSHA256: strings.Repeat("ab", 32), + Path: "plugins/Essentials", Dir: true, TargetURL: "http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/exports/0011223344556677", Token: secretToken, Namespace: "minecraft", ServiceAccount: "felis-restore", Image: "felis:1", @@ -37,7 +38,8 @@ func TestExportJobIsolation(t *testing.T) { args []string }{ {ModeWorld, worldVolume, "world-survival-0", "/world", []string{"--worlds-root", "/world"}}, - {ModeBackup, backupVolume, "felis-backups", "/backups", []string{"--ref", "/backups/survival-1.tar.gz", "--backup-root", "/backups"}}, + {ModeBackup, backupVolume, "felis-backups", "/backups", []string{"--ref", "/backups/survival-1.tar.gz", "--backup-root", "/backups", "--sha256", strings.Repeat("ab", 32)}}, + {ModeFiles, worldVolume, "world-survival-0", "/world", []string{"--worlds-root", "/world", "--path", "plugins/Essentials", "--dir"}}, } { job, err := ExportJob(params(tc.mode)) if err != nil { @@ -132,6 +134,40 @@ func TestExportJobRefusesIncompleteParams(t *testing.T) { if _, err := ExportJob(p); err == nil { t.Error("a backup export without a ref was accepted") } + p = params(ModeFiles) + p.Path = "" + if _, err := ExportJob(p); err == nil { + t.Error("a files export without a path was accepted") + } + p = params(ModeFiles) + p.WorldPVC = "" + if _, err := ExportJob(p); err == nil { + t.Error("a files export without a world claim was accepted") + } +} + +// TestExportJobOptionalArgs: a backup with no recorded digest carries no +// --sha256, and a file download carries its path and no --dir. +func TestExportJobOptionalArgs(t *testing.T) { + p := params(ModeBackup) + p.BackupSHA256 = "" + job, err := ExportJob(p) + if err != nil { + t.Fatal(err) + } + want := []string{"--ref", "/backups/survival-1.tar.gz", "--backup-root", "/backups"} + if args := job.Spec.Template.Spec.Containers[0].Args; !slices.Equal(args[len(args)-len(want):], want) || slices.Contains(args, "--sha256") { + t.Errorf("backup args = %v", args) + } + p = params(ModeFiles) + p.Dir = false + if job, err = ExportJob(p); err != nil { + t.Fatal(err) + } + want = []string{"--worlds-root", "/world", "--path", "plugins/Essentials"} + if args := job.Spec.Template.Spec.Containers[0].Args; !slices.Equal(args[len(args)-len(want):], want) || slices.Contains(args, "--dir") { + t.Errorf("file args = %v", args) + } } // TestExportJobDefaults: a caller that leaves the deadline and the TTL unset @@ -175,4 +211,28 @@ func TestStartCreatesTheJob(t *testing.T) { TargetURL: "http://api:8081/x", Token: secretToken}); err == nil { t.Error("a second Job of the same name was reported as created") } + + // Each mode's own fields reach the Pod's arguments. + for _, tc := range []struct { + r Request + tail []string + }{ + {Request{Server: "survival", Mode: ModeBackup, ID: "1111111111111111", BackupRef: "/backups/a.tar.gz", BackupSHA256: strings.Repeat("cd", 32)}, + []string{"--ref", "/backups/a.tar.gz", "--backup-root", "/backups", "--sha256", strings.Repeat("cd", 32)}}, + {Request{Server: "survival", Mode: ModeFiles, ID: "2222222222222222", Path: "plugins/Essentials", Dir: true}, + []string{"--worlds-root", "/world", "--path", "plugins/Essentials", "--dir"}}, + } { + tc.r.TargetURL, tc.r.Token = "http://api:8081/x", secretToken + name, err := e.Start(context.Background(), tc.r) + if err != nil { + t.Fatalf("%s: Start: %v", tc.r.Mode, err) + } + job, err := cs.BatchV1().Jobs("minecraft").Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if args := job.Spec.Template.Spec.Containers[0].Args; !slices.Equal(args[len(args)-len(tc.tail):], tc.tail) { + t.Errorf("%s: args = %v, want them to end %v", tc.r.Mode, args, tc.tail) + } + } } diff --git a/internal/worldexport/worldexport.go b/internal/worldexport/worldexport.go index 48ad3f8..1158319 100644 --- a/internal/worldexport/worldexport.go +++ b/internal/worldexport/worldexport.go @@ -1,11 +1,12 @@ // Package worldexport is the executor behind the world export routes (POST -// /servers/{name}/world/export and POST /servers/{name}/backups/{id}/export): a +// /servers/{name}/world/export and POST /servers/{name}/backups/{id}/export) +// and the file manager's download (POST /servers/{name}/files/download): a // one-shot Job in the minecraft namespace that reads a stopped server's world, -// or one of its stored archives, and PUTs the tar.gz to felis-api's internal -// face, which streams it on to the owner's browser as it arrives -// (internal/api/exports.go). Nothing is staged on the way: felis-api never -// mounts a world or the backup store, and the archive never lands on a disk it -// owns. +// one of its stored archives, or one file or folder of the world, and PUTs it +// to felis-api's internal face, which streams it on to the owner's browser as +// it arrives (internal/api/exports.go). Nothing is staged on the way: felis-api +// never mounts a world or the backup store, and what is sent never lands on a +// disk it owns. // // Trust model as in internal/restore: the Pod runs under the weak felis-restore // SA with no API token, mounts one volume read-only, and holds no database URL @@ -26,9 +27,14 @@ import ( // Request is one export as felis-api admitted it. type Request struct { Server string - Mode string // ModeWorld or ModeBackup - // BackupRef is the archive a ModeBackup export reads. - BackupRef string + Mode string // ModeWorld, ModeBackup or ModeFiles + // BackupRef is the archive a ModeBackup export reads, and BackupSHA256 what + // it must hash to. + BackupRef string + BackupSHA256 string + // Path and Dir are the file or folder a ModeFiles export sends. + Path string + Dir bool // ID names the export (JobName) and TargetURL/Token are where and how the // Pod hands the archive over. ID string @@ -108,6 +114,7 @@ func (e *Exporter) Start(ctx context.Context, r Request) (string, error) { job, err := ExportJob(JobParams{ Server: r.Server, ID: r.ID, Mode: r.Mode, WorldPVC: naming.WorldPVCName(r.Server), BackupPVC: c.BackupPVC, BackupRef: r.BackupRef, + BackupSHA256: r.BackupSHA256, Path: r.Path, Dir: r.Dir, TargetURL: r.TargetURL, Token: r.Token, Namespace: c.Namespace, ServiceAccount: c.ServiceAccount, Image: c.Image, BackupRoot: c.BackupRoot, WorldsRoot: c.WorldsRoot, Deadline: c.Deadline, diff --git a/panel/src/components/ConfirmDialog.tsx b/panel/src/components/ConfirmDialog.tsx index 808831b..4641cdf 100644 --- a/panel/src/components/ConfirmDialog.tsx +++ b/panel/src/components/ConfirmDialog.tsx @@ -23,6 +23,8 @@ interface Props { /** Runs the action. The dialog closes when it resolves; a rejection is shown in * the dialog, which stays open so the action can be retried or dismissed. */ onConfirm: () => Promise; + /** Shown under the description, for what a sentence cannot hold (a list). */ + children?: ReactNode; } // ConfirmDialog asks before an action that cannot be taken back, in the panel's @@ -38,6 +40,7 @@ export function ConfirmDialog({ cancelLabel, confirmVariant = "destructive", onConfirm, + children, }: Props) { const { t } = useTranslation("common"); const [busy, setBusy] = useState(false); @@ -69,6 +72,7 @@ export function ConfirmDialog({ {title} {description && {description}} + {children} {error && } setOpen(false)} diff --git a/panel/src/components/files/FileOps.test.tsx b/panel/src/components/files/FileOps.test.tsx new file mode 100644 index 0000000..56896cd --- /dev/null +++ b/panel/src/components/files/FileOps.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import i18next from "i18next"; +import type { FileOp } from "@/lib/types"; +import { humanizeError } from "@/lib/api"; +import { FileOps } from "./FileOps"; +import { opErrorText } from "./opText"; + +const t = (key: string, opts?: Record) => i18next.t(`files:${key}`, opts ?? {}); + +function op(over: Partial = {}): FileOp { + return { id: "a", op: "unzip", path: "pack.zip", state: "running", started_at: "2026-09-28T00:00:00Z", done: 0, total: 0, ...over }; +} + +function show(ops: FileOp[], error: unknown = null) { + const onDismiss = vi.fn(); + const onConflicts = vi.fn(); + const view = render(); + return { ...view, onDismiss, onConflicts }; +} + +describe("FileOps", () => { + it("shows nothing with no ops and nothing to say", () => { + const { container } = show([]); + expect(container.innerHTML).toBe(""); + }); + + it("counts a running op in whole percents, rounding down and never past 100", () => { + show([op({ id: "a", path: "a.zip", done: 999, total: 1000 }), op({ id: "b", path: "b.zip", done: 5, total: 4 })]); + + expect(screen.getByText(t("op_progress", { done: "999 B", total: "1000 B", percent: 99 }))).toBeTruthy(); + expect(screen.getByText(t("op_progress", { done: "5 B", total: "4 B", percent: 100 }))).toBeTruthy(); + const bars = screen.getAllByRole("progressbar"); + expect(bars.map((b) => [b.getAttribute("aria-label"), b.getAttribute("aria-valuenow")])).toEqual([ + [t("op_progress_label", { path: "a.zip" }), "99"], + [t("op_progress_label", { path: "b.zip" }), "100"], + ]); + }); + + it("claims no progress before the Job has counted, and offers no dismissal while it runs", () => { + show([op()]); + + expect(screen.getByText(t("op_preparing"))).toBeTruthy(); + expect(screen.getByRole("progressbar").hasAttribute("aria-valuenow")).toBe(false); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("words how each kind of op ended", () => { + show([ + op({ id: "u", op: "upload", path: "big.jar", state: "succeeded" }), + op({ id: "z", path: "pack.zip", state: "succeeded", files: 1, bytes: 2048 }), + op({ id: "f", op: "upload", path: "lost.jar", state: "failed" }), + ]); + + expect(screen.getByText(t("op_upload", { path: "big.jar" }))).toBeTruthy(); + expect(screen.getByText(t("op_upload_done"))).toBeTruthy(); + expect(screen.getByText(t("op_unzip", { path: "pack.zip" }))).toBeTruthy(); + expect(screen.getByText(t("op_unzip_done", { count: 1, bytes: "2.0 KiB" }))).toBeTruthy(); + // A failure the Job left unexplained still says something. + expect(screen.getByText(opErrorText("upload", { code: "job_failed", message: "" }))).toBeTruthy(); + expect(screen.queryByRole("progressbar")).toBeNull(); + }); + + it("offers the conflicts of an extraction only, and dismisses each ended op by its id", () => { + const exists = { code: "file_exists", message: "", conflicts: ["a.txt"] }; + const refused = op({ id: "z", path: "pack.zip", state: "failed", error: exists }); + const { onDismiss, onConflicts } = show([ + refused, + op({ id: "u", op: "upload", path: "a.txt", state: "failed", error: exists }), + op({ id: "bad", path: "bad.zip", state: "failed", error: { code: "archive_invalid", message: "" } }), + ]); + + const conflicts = screen.getAllByRole("button", { name: t("op_conflicts") }); + expect(conflicts).toHaveLength(1); + fireEvent.click(conflicts[0]); + expect(onConflicts.mock.calls).toEqual([[refused]]); + + fireEvent.click(screen.getByRole("button", { name: t("op_dismiss", { path: "a.txt" }) })); + expect(onDismiss.mock.calls).toEqual([["u"]]); + }); + + it("says why the ops could not be read, over the ones it has", () => { + const broke = { status: 500, code: "internal", message: "" }; + show([op({ state: "succeeded", files: 1, bytes: 8 })], broke); + + const region = screen.getByRole("region", { name: t("ops_label") }); + expect(within(region).getByText(t("ops_refresh_failed", { reason: humanizeError(broke) }))).toBeTruthy(); + expect(within(region).getByText(t("op_unzip_done", { count: 1, bytes: "8 B" }))).toBeTruthy(); + }); +}); diff --git a/panel/src/components/files/FileOps.tsx b/panel/src/components/files/FileOps.tsx new file mode 100644 index 0000000..f5fed76 --- /dev/null +++ b/panel/src/components/files/FileOps.tsx @@ -0,0 +1,134 @@ +import { useTranslation } from "react-i18next"; +import { AlertCircle, CheckCircle2, FileArchive, Loader2, Upload, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { MessageLine } from "@/components/MessageLine"; +import { humanizeError } from "@/lib/api"; +import { formatBytes } from "@/lib/format"; +import type { FileOp } from "@/lib/types"; +import { cn } from "@/lib/utils"; +import { opErrorText } from "./opText"; + +interface Props { + ops: readonly FileOp[]; + /** Why the latest read of the ops failed, if it did. */ + error: unknown; + onDismiss: (id: string) => void; + /** Opens the list of files an extraction stopped short of replacing. */ + onConflicts: (op: FileOp) => void; +} + +// FileOps shows the server's background operations: how far a running one has +// got, and how each recent one ended, until it is dismissed. +export function FileOps({ ops, error, onDismiss, onConflicts }: Props) { + const { t } = useTranslation("files"); + if (ops.length === 0 && error == null) return null; + + return ( +
+ {error != null && ( +
+ +
+ )} +
    + {ops.map((op) => ( + onDismiss(op.id)} onConflicts={() => onConflicts(op)} /> + ))} +
+
+ ); +} + +function OpRow({ op, onDismiss, onConflicts }: { op: FileOp; onDismiss: () => void; onConflicts: () => void }) { + const { t } = useTranslation("files"); + const title = t(op.op === "unzip" ? "op_unzip" : "op_upload", { path: op.path }); + const Kind = op.op === "unzip" ? FileArchive : Upload; + const counted = op.total > 0; + const percent = counted ? Math.min(100, Math.floor((op.done * 100) / op.total)) : 0; + const failure = op.error ?? { code: "job_failed", message: "" }; + const conflicts = op.state === "failed" && op.op === "unzip" && failure.code === "file_exists"; + + let status: string; + if (op.state === "running") { + status = counted + ? t("op_progress", { done: formatBytes(op.done), total: formatBytes(op.total), percent }) + : t("op_preparing"); + } else if (op.state === "succeeded") { + status = + op.op === "unzip" + ? t("op_unzip_done", { count: op.files ?? 0, bytes: formatBytes(op.bytes ?? 0) }) + : t("op_upload_done"); + } else { + status = opErrorText(op.op, failure); + } + + const icon = + op.state === "running" ? ( + + ) : op.state === "succeeded" ? ( + + ) : ( + + ); + + return ( +
  • +
    + {icon} +
    +

    + + + {title} + +

    +

    + {status} +

    + {op.state === "running" && ( +
    +
    +
    + )} +
    + {op.state !== "running" && ( +
    + {conflicts && ( + + )} + +
    + )} +
    +
  • + ); +} diff --git a/panel/src/components/files/UploadQueue.tsx b/panel/src/components/files/UploadQueue.tsx index f3666b9..c28f561 100644 --- a/panel/src/components/files/UploadQueue.tsx +++ b/panel/src/components/files/UploadQueue.tsx @@ -79,10 +79,16 @@ function UploadRow({ }) { const { t } = useTranslation("files"); const { file, state } = item; - const percent = file.size > 0 ? Math.min(100, Math.floor((item.sent * 100) / file.size)) : 100; - // The body is all sent and the Job is landing it: seconds more, with no bytes - // left to count. + // The body is all sent and the Job is landing it. A file sent in one request + // lands in seconds, with no bytes left to count; one sent in parts is fetched + // by its Job afresh, which reports how far it has got. const landing = state === "uploading" && item.sent >= file.size; + const written = landing && item.landing !== null && item.landing.total > 0 ? item.landing : null; + const percent = written + ? Math.min(100, Math.floor((written.done * 100) / written.total)) + : file.size > 0 + ? Math.min(100, Math.floor((item.sent * 100) / file.size)) + : 100; const icon = { queued: , @@ -94,6 +100,7 @@ function UploadRow({ let status: string; if (state === "queued") status = t("upload_queued"); + else if (written) status = t("upload_landing_progress", { percent }); else if (landing) status = t("upload_landing"); else if (state === "uploading") status = t("upload_sending", { sent: formatBytes(item.sent), total: formatBytes(file.size), percent }); else if (state === "done") status = t("upload_done"); @@ -140,7 +147,7 @@ function UploadRow({
    diff --git a/panel/src/components/files/opText.test.ts b/panel/src/components/files/opText.test.ts new file mode 100644 index 0000000..529cd52 --- /dev/null +++ b/panel/src/components/files/opText.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { humanizeError } from "@/lib/api"; +import { opErrorText } from "./opText"; + +const e = (code: string, over: Record = {}) => ({ code, message: "raw words", ...over }); + +describe("opErrorText", () => { + it("tells an upload that found its file there from an extraction that would replace files", () => { + expect(opErrorText("upload", e("file_exists"))).toBe( + "A file with this name is already here, so nothing was replaced. Upload it again and choose Replace.", + ); + expect(opErrorText("unzip", e("file_exists", { conflicts: ["a", "b"], conflict_count: 250 }))).toBe( + "It would replace 250 files already here, so nothing was extracted yet.", + ); + // Without the count, the list is counted. + expect(opErrorText("unzip", e("file_exists", { conflicts: ["a"] }))).toBe( + "It would replace 1 file already here, so nothing was extracted yet.", + ); + }); + + it("names both sizes of a volume too small, and falls back when they are missing", () => { + expect(opErrorText("unzip", e("volume_full", { need: 3 * 1024 * 1024, avail: 1024 }))).toBe( + "Not enough room on the world volume: 3.0 MiB needed, 1.0 KiB free. Nothing was changed.", + ); + expect(opErrorText("upload", e("volume_full"))).toBe(humanizeError({ status: 0, code: "volume_full", message: "raw words" })); + }); + + it("names the archive entry at fault", () => { + expect(opErrorText("unzip", e("archive_invalid"))).toBe("The archive is damaged, or not a zip file. Nothing was changed."); + expect(opErrorText("unzip", e("archive_invalid", { entry: "world/level.dat" }))).toBe( + "world/level.dat in the archive is damaged (its size or checksum does not match). Nothing was changed.", + ); + expect(opErrorText("unzip", e("archive_unsafe", { entry: "../../etc/passwd" }))).toBe( + "../../etc/passwd in the archive would land outside this folder, or is a device file. Nothing in the archive was extracted.", + ); + expect(opErrorText("unzip", e("archive_symlink", { entry: "world/link" }))).toBe( + "world/link in the archive is a symbolic link. Nothing in the archive was extracted.", + ); + expect(opErrorText("unzip", e("type_conflict", { entry: "plugins" }))).toBe( + "plugins is a file on one side and a folder on the other, which replacing cannot resolve. Rename or delete plugins here, then extract again.", + ); + }); + + it("gives a Job that ran out of time its own words", () => { + expect(opErrorText("unzip", e("job_failed", { message: "job failed: DeadlineExceeded" }))).toBe( + "The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again.", + ); + expect(opErrorText("upload", e("job_failed", { message: "job failed: BackoffLimitExceeded" }))).toBe( + "The background task ended without saying why. Refresh the list to check, then try again.", + ); + }); + + it("words any other code as the file routes do", () => { + expect(opErrorText("upload", e("file_changed"))).toBe(humanizeError({ status: 0, code: "file_changed", message: "raw words" })); + expect(opErrorText("upload", e("file_changed"))).not.toBe("raw words"); + }); +}); diff --git a/panel/src/components/files/opText.ts b/panel/src/components/files/opText.ts new file mode 100644 index 0000000..6945300 --- /dev/null +++ b/panel/src/components/files/opText.ts @@ -0,0 +1,34 @@ +import i18next from "i18next"; +import { humanizeError } from "@/lib/api"; +import { formatBytes } from "@/lib/format"; +import type { FileOp, FileOpError } from "@/lib/types"; + +/** opErrorText says why an op ended failed. The codes an extraction refuses an + * archive with name the entry at fault; the rest are the codes the file routes + * answer, worded as they are there. */ +export function opErrorText(op: FileOp["op"], e: FileOpError): string { + const t = i18next.getFixedT(null, "files"); + const entry = e.entry ?? ""; + switch (e.code) { + case "file_exists": + return op === "upload" + ? t("op_upload_exists") + : t("op_unzip_conflicts", { count: e.conflict_count ?? e.conflicts?.length ?? 0 }); + case "volume_full": + return e.need !== undefined && e.avail !== undefined + ? t("op_volume_full", { need: formatBytes(e.need), avail: formatBytes(e.avail) }) + : humanizeError({ status: 0, code: e.code, message: e.message }); + case "archive_invalid": + return entry ? t("archive_invalid_entry", { entry }) : t("archive_invalid"); + case "archive_unsafe": + case "archive_symlink": + case "type_conflict": + return t(e.code, { entry }); + // The Job's own condition reason rides in the message; a deadline is the + // one worth its own words. + case "job_failed": + return e.message.includes("DeadlineExceeded") ? t("job_timed_out") : t("job_failed"); + default: + return humanizeError({ status: 0, code: e.code, message: e.message }); + } +} diff --git a/panel/src/components/files/sessionUpload.test.ts b/panel/src/components/files/sessionUpload.test.ts new file mode 100644 index 0000000..e8c684c --- /dev/null +++ b/panel/src/components/files/sessionUpload.test.ts @@ -0,0 +1,452 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { FileOp, FileUploadSession } from "@/lib/types"; +import { MAX_ATTEMPTS } from "@/lib/contextUpload"; +import { + MAX_OP_MISSES, + OP_POLL_MS, + STALE_SESSION_MS, + discardSession, + forgetSession, + sendInParts, + watchOp, +} from "./sessionUpload"; + +const mocks = vi.hoisted(() => ({ + getServerFileUpload: vi.fn(), + beginServerFileUpload: vi.fn(), + putServerFileUploadPart: vi.fn(), + deleteServerFileUpload: vi.fn(), + commitServerFileUpload: vi.fn(), + listServerFileOps: vi.fn(), +})); + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, ...mocks } }; +}); + +const KEY = "felis-file-upload:lobby:world.zip"; +const PART = 4; +const NOW = 1_000_000_000; + +// A 10-byte file sent in parts of 4: offsets 0, 4 and 8. +function file(body = "0123456789", modified = 111) { + return new File([body], "world.zip", { lastModified: modified }); +} + +function session(received: number, over: Partial = {}): FileUploadSession { + return { id: "s1", path: "world.zip", size: 10, received, part_max_bytes: PART, ...over }; +} + +function op(over: Partial = {}): FileOp { + return { + id: "op1", + op: "upload", + path: "world.zip", + state: "running", + started_at: "2026-09-28T00:00:00Z", + done: 0, + total: 0, + ...over, + }; +} + +const netErr = { status: 0, code: "network_error", message: "" }; +const gone = { status: 404, code: "upload_not_found", message: "no such upload" }; +const held = { status: 409, code: "maintenance_in_progress", message: "held" }; + +// The server takes every part whole and reports where the session stands. +function serverTakesParts() { + mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => + session(offset + part.size, { id }), + ); +} + +// Pauses at once. A loop that never gives up would never yield either, so +// the hundredth pause fails the test instead of hanging it. +const sleep = vi.fn(async (_ms: number, _signal?: AbortSignal) => { + if (sleep.mock.calls.length > 100) throw new Error("runaway retry loop"); +}); +const opts = (over: Partial[3]> = {}) => ({ + overwrite: false, + sleep, + now: () => NOW, + ...over, +}); +const offsets = () => mocks.putServerFileUploadPart.mock.calls.map((c) => c[2]); +const stored = () => JSON.parse(localStorage.getItem(KEY) ?? "null"); + +beforeEach(() => { + for (const m of Object.values(mocks)) m.mockReset(); + sleep.mockClear(); + localStorage.clear(); + mocks.beginServerFileUpload.mockResolvedValue(session(0)); + mocks.commitServerFileUpload.mockResolvedValue({ op: op() }); + serverTakesParts(); +}); + +describe("sendInParts", () => { + it("sends each part at the offset the server has reached, then commits", async () => { + const seen: number[] = []; + const sessions: string[] = []; + + const got = await sendInParts("lobby", "world.zip", file(), opts({ + overwrite: true, + onProgress: (n) => seen.push(n), + onSession: (id) => sessions.push(id), + })); + + expect(got).toEqual(op()); + expect(mocks.beginServerFileUpload.mock.calls).toEqual([["lobby", "world.zip", 10]]); + expect(offsets()).toEqual([0, 4, 8]); + expect(mocks.putServerFileUploadPart.mock.calls.map((c) => (c[3] as Blob).size)).toEqual([4, 4, 2]); + expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s1", true]]); + expect(seen).toEqual([0, 4, 8, 10]); + expect(sessions).toEqual(["s1"]); + }); + + it("remembers the session under the server and path, with the file it holds, touched at each part", async () => { + const f = file("0123456789", 777); + let clock = NOW; + let seenMidway: unknown = null; + mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => { + if (offset === 4) seenMidway = stored(); + clock += 1000; + return session(offset + part.size, { id }); + }); + + await sendInParts("lobby", "world.zip", f, opts({ now: () => clock })); + + expect(seenMidway).toEqual({ server: "lobby", path: "world.zip", id: "s1", size: 10, modified: 777, touched: NOW + 1000 }); + expect(stored().touched).toBe(NOW + 3000); + }); + + it("carries on a remembered session from where the server says it stands", async () => { + localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 })); + mocks.getServerFileUpload.mockResolvedValue(session(8, { id: "s7" })); + + await sendInParts("lobby", "world.zip", file(), opts()); + + expect(mocks.getServerFileUpload.mock.calls).toEqual([["lobby", "s7"]]); + expect(mocks.beginServerFileUpload).not.toHaveBeenCalled(); + expect(offsets()).toEqual([8]); + expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s7", false]]); + }); + + it("commits at once when the server already holds the whole file", async () => { + localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 })); + mocks.getServerFileUpload.mockResolvedValue(session(10, { id: "s7" })); + + await sendInParts("lobby", "world.zip", file(), opts()); + + expect(offsets()).toEqual([]); + expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s7", false]]); + }); + + it.each([ + ["a different size", { size: 11, modified: 111 }], + ["a different modification time", { size: 10, modified: 112 }], + ])("starts over when the remembered session was for %s", async (_label, held) => { + localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", touched: 0, ...held })); + + await sendInParts("lobby", "world.zip", file(), opts()); + + expect(mocks.getServerFileUpload).not.toHaveBeenCalled(); + expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(1); + expect(offsets()).toEqual([0, 4, 8]); + }); + + it.each([ + ["is gone", () => mocks.getServerFileUpload.mockRejectedValue(gone)], + ["now stands for another file", () => mocks.getServerFileUpload.mockResolvedValue(session(8, { id: "s7", size: 99 }))], + ])("begins a new session when the remembered one %s", async (_label, arrange) => { + localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 })); + arrange(); + + await sendInParts("lobby", "world.zip", file(), opts()); + + expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(1); + expect(offsets()).toEqual([0, 4, 8]); + expect(stored().id).toBe("s1"); + expect(sleep).not.toHaveBeenCalled(); + }); + + it("gives back only the stale sessions this browser left behind when there are too many, then begins", async () => { + const old = { server: "lobby", path: "old.zip", id: "old", size: 5, modified: 1, touched: NOW - STALE_SESSION_MS }; + const fresh = { server: "lobby", path: "fresh.zip", id: "fresh", size: 5, modified: 1, touched: NOW - STALE_SESSION_MS + 1 }; + localStorage.setItem("felis-file-upload:lobby:old.zip", JSON.stringify(old)); + localStorage.setItem("felis-file-upload:lobby:fresh.zip", JSON.stringify(fresh)); + localStorage.setItem("unrelated", JSON.stringify({ id: "x", server: "lobby", path: "x" })); + mocks.beginServerFileUpload + .mockRejectedValueOnce({ status: 429, code: "too_many_uploads", message: "" }) + .mockResolvedValueOnce(session(0)); + mocks.deleteServerFileUpload.mockResolvedValue(null); + + await sendInParts("lobby", "world.zip", file(), opts()); + + expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "old"]]); + expect(localStorage.getItem("felis-file-upload:lobby:old.zip")).toBeNull(); + expect(localStorage.getItem("felis-file-upload:lobby:fresh.zip")).not.toBeNull(); + expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(2); + expect(offsets()).toEqual([0, 4, 8]); + }); + + it("reports too many uploads when none of this browser's sessions could be given back", async () => { + const refusal = { status: 429, code: "too_many_uploads", message: "" }; + const old = { server: "lobby", path: "old.zip", id: "old", size: 5, modified: 1, touched: 0 }; + localStorage.setItem("felis-file-upload:lobby:old.zip", JSON.stringify(old)); + mocks.beginServerFileUpload.mockRejectedValue(refusal); + mocks.deleteServerFileUpload.mockRejectedValue(gone); + + await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(refusal); + + expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(1); + expect(offsets()).toEqual([]); + }); + + it("asks where the session stands after a dropped part, and resends from there", async () => { + let first = true; + mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => { + if (offset === 4 && first) { + first = false; + throw netErr; + } + return session(offset + part.size, { id }); + }); + // Half of the dropped part had arrived. + mocks.getServerFileUpload.mockResolvedValue(session(6)); + + await sendInParts("lobby", "world.zip", file(), opts()); + + expect(mocks.getServerFileUpload.mock.calls).toEqual([["lobby", "s1"]]); + expect(offsets()).toEqual([0, 4, 6]); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([1000]); + }); + + it("begins a new session when the one in use went away mid-upload", async () => { + mocks.putServerFileUploadPart + .mockImplementationOnce(async (_n: string, id: string, offset: number, part: Blob) => session(offset + part.size, { id })) + .mockRejectedValueOnce(gone); + mocks.beginServerFileUpload.mockResolvedValueOnce(session(0)).mockResolvedValueOnce(session(0, { id: "s2" })); + + await sendInParts("lobby", "world.zip", file(), opts()); + + expect(mocks.getServerFileUpload).not.toHaveBeenCalled(); + expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(2); + expect(offsets()).toEqual([0, 4, 0, 4, 8]); + expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s2", false]]); + }); + + it("stops at a refusal sending again cannot change", async () => { + const full = { status: 507, code: "upload_staging_full", message: "" }; + mocks.putServerFileUploadPart.mockRejectedValue(full); + + await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(full); + + expect(offsets()).toEqual([0]); + expect(sleep).not.toHaveBeenCalled(); + }); + + it(`gives up after ${MAX_ATTEMPTS} dropped attempts in a row`, async () => { + mocks.putServerFileUploadPart.mockRejectedValue(netErr); + mocks.getServerFileUpload.mockResolvedValue(session(0)); + + await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(netErr); + + expect(offsets()).toHaveLength(MAX_ATTEMPTS); + }); + + it("counts only drops in a row", async () => { + let received = 0; + let drops = 0; + mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => { + // Each part lands only after MAX_ATTEMPTS - 1 drops. + if (drops++ < MAX_ATTEMPTS - 1) throw netErr; + drops = 0; + received = offset + part.size; + return session(received, { id }); + }); + mocks.getServerFileUpload.mockImplementation(async () => session(received)); + + await sendInParts("lobby", "world.zip", file(), opts()); + + expect(offsets()).toHaveLength(3 * MAX_ATTEMPTS); + expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1); + }); + + it("keeps the session remembered when stopped, even in its first part, for a later resume", async () => { + const ctrl = new AbortController(); + mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => { + if (offset === 0) { + ctrl.abort(); + throw new DOMException("cancelled", "AbortError"); + } + return session(offset + part.size, { id }); + }); + + await expect(sendInParts("lobby", "world.zip", file(), opts({ signal: ctrl.signal }))).rejects.toMatchObject({ + name: "AbortError", + }); + + expect(mocks.putServerFileUploadPart.mock.calls[0][4]).toMatchObject({ signal: ctrl.signal }); + expect(stored().id).toBe("s1"); + expect(mocks.deleteServerFileUpload).not.toHaveBeenCalled(); + }); + + describe("a commit whose answer was lost", () => { + it("takes the running upload of this path as its answer when the world is held", async () => { + const landing = op({ id: "op9" }); + mocks.commitServerFileUpload.mockRejectedValueOnce(netErr).mockRejectedValueOnce(held); + mocks.listServerFileOps.mockResolvedValue({ + ops: [op({ id: "other", path: "else.zip" }), op({ id: "old", state: "failed" }), landing], + }); + + expect(await sendInParts("lobby", "world.zip", file(), opts())).toEqual(landing); + expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(2); + }); + + it("takes the upload of this path in any state when the session is already gone", async () => { + const landed = op({ id: "op9", state: "succeeded" }); + mocks.commitServerFileUpload.mockRejectedValueOnce(netErr).mockRejectedValueOnce(gone); + mocks.listServerFileOps.mockResolvedValue({ ops: [op({ id: "u", op: "unzip" }), landed] }); + + expect(await sendInParts("lobby", "world.zip", file(), opts())).toEqual(landed); + }); + + it("reports the session gone when no upload of this path is there", async () => { + mocks.commitServerFileUpload.mockRejectedValueOnce(netErr).mockRejectedValueOnce(gone); + mocks.listServerFileOps.mockResolvedValue({ ops: [op({ path: "else.zip" })] }); + + await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(gone); + }); + + it("asks again while the world is held by something else", async () => { + mocks.commitServerFileUpload + .mockRejectedValueOnce(netErr) + .mockRejectedValueOnce(held) + .mockResolvedValueOnce({ op: op({ id: "op2" }) }); + mocks.listServerFileOps.mockResolvedValue({ ops: [op({ id: "done", state: "succeeded" })] }); + + expect(await sendInParts("lobby", "world.zip", file(), opts())).toEqual(op({ id: "op2" })); + expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(3); + }); + }); + + it(`gives up a commit after ${MAX_ATTEMPTS} attempts that did not get through`, async () => { + mocks.commitServerFileUpload.mockRejectedValue(netErr); + + await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(netErr); + + expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(MAX_ATTEMPTS); + }); + + it("reads a held world on the first commit as the refusal it is", async () => { + mocks.commitServerFileUpload.mockRejectedValue(held); + + await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(held); + + expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1); + expect(mocks.listServerFileOps).not.toHaveBeenCalled(); + }); +}); + +describe("watchOp", () => { + const watch = (over: Parameters[2] = {}) => watchOp("lobby", "op1", { sleep, ...over }); + + it("reads every OP_POLL_MS, reports progress, and answers the op once it ends", async () => { + const seen: number[] = []; + mocks.listServerFileOps + .mockResolvedValueOnce({ ops: [op({ done: 3, total: 10 })] }) + .mockResolvedValueOnce({ ops: [op({ id: "x", state: "succeeded" }), op({ done: 7, total: 10 })] }) + .mockResolvedValueOnce({ ops: [op({ state: "succeeded", done: 10, total: 10 })] }); + + const end = await watch({ onProgress: (o) => seen.push(o.done) }); + + expect(end).toEqual(op({ state: "succeeded", done: 10, total: 10 })); + expect(seen).toEqual([3, 7]); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([OP_POLL_MS, OP_POLL_MS, OP_POLL_MS]); + }); + + it("backs off after a read that did not get through, and carries on", async () => { + mocks.listServerFileOps + .mockRejectedValueOnce(netErr) + .mockRejectedValueOnce(netErr) + .mockRejectedValueOnce(netErr) + .mockResolvedValueOnce({ ops: [op()] }) + .mockResolvedValueOnce({ ops: [op({ state: "failed" })] }); + + expect((await watch()).state).toBe("failed"); + expect(sleep.mock.calls.map((c) => c[0])).toEqual([OP_POLL_MS, 1000, 2000, 4000, OP_POLL_MS]); + }); + + it(`gives up after ${MAX_ATTEMPTS} reads in a row did not get through`, async () => { + mocks.listServerFileOps.mockRejectedValue(netErr); + + await expect(watch()).rejects.toBe(netErr); + expect(mocks.listServerFileOps).toHaveBeenCalledTimes(MAX_ATTEMPTS); + }); + + it("stops at a read refused for good", async () => { + const refused = { status: 403, code: "forbidden", message: "" }; + mocks.listServerFileOps.mockRejectedValue(refused); + + await expect(watch()).rejects.toBe(refused); + expect(mocks.listServerFileOps).toHaveBeenCalledTimes(1); + }); + + it(`reports the op lost after ${MAX_OP_MISSES} reads in a row without it`, async () => { + mocks.listServerFileOps.mockResolvedValue({ ops: [op({ id: "other" })] }); + + await expect(watch()).rejects.toMatchObject({ code: "op_lost" }); + expect(mocks.listServerFileOps).toHaveBeenCalledTimes(MAX_OP_MISSES); + }); + + it("counts only misses in a row", async () => { + const without = { ops: [] }; + for (let i = 0; i < MAX_OP_MISSES - 1; i++) mocks.listServerFileOps.mockResolvedValueOnce(without); + mocks.listServerFileOps.mockResolvedValueOnce({ ops: [op()] }); + for (let i = 0; i < MAX_OP_MISSES - 1; i++) mocks.listServerFileOps.mockResolvedValueOnce(without); + mocks.listServerFileOps.mockResolvedValueOnce({ ops: [op({ state: "succeeded" })] }); + + expect((await watch()).state).toBe("succeeded"); + }); +}); + +describe("discardSession and forgetSession", () => { + it("forgets the session and cancels it, waiting out a part still arriving", async () => { + localStorage.setItem(KEY, "{}"); + mocks.deleteServerFileUpload + .mockRejectedValueOnce({ status: 409, code: "upload_busy", message: "" }) + .mockResolvedValueOnce(null); + + await discardSession("lobby", "world.zip", "s1", sleep); + + expect(localStorage.getItem(KEY)).toBeNull(); + expect(mocks.deleteServerFileUpload.mock.calls).toEqual([ + ["lobby", "s1"], + ["lobby", "s1"], + ]); + }); + + it("tries three times at most, and never past another refusal", async () => { + mocks.deleteServerFileUpload.mockRejectedValue({ status: 409, code: "upload_busy", message: "" }); + await discardSession("lobby", "world.zip", "s1", sleep); + expect(mocks.deleteServerFileUpload).toHaveBeenCalledTimes(3); + + mocks.deleteServerFileUpload.mockReset(); + mocks.deleteServerFileUpload.mockRejectedValue(gone); + await discardSession("lobby", "world.zip", "s1", sleep); + expect(mocks.deleteServerFileUpload).toHaveBeenCalledTimes(1); + }); + + it("forgetSession drops only the one path", () => { + localStorage.setItem(KEY, "{}"); + localStorage.setItem("felis-file-upload:lobby:other.zip", "{}"); + + forgetSession("lobby", "world.zip"); + + expect(localStorage.getItem(KEY)).toBeNull(); + expect(localStorage.getItem("felis-file-upload:lobby:other.zip")).toBe("{}"); + }); +}); diff --git a/panel/src/components/files/sessionUpload.ts b/panel/src/components/files/sessionUpload.ts new file mode 100644 index 0000000..7d930e6 --- /dev/null +++ b/panel/src/components/files/sessionUpload.ts @@ -0,0 +1,293 @@ +import { api, clientError } from "@/lib/api"; +import { MAX_ATTEMPTS, isTransient, retryDelay, wait } from "@/lib/contextUpload"; +import type { FileOp, FileUploadSession } from "@/lib/types"; + +// A file bigger than one request carries goes up as a session (api +// beginServerFileUpload): parts of at most part_max_bytes, each at the byte +// offset the session has reached, so a dropped connection resumes where the +// server says it stands rather than starting over. The commit answers at once +// with the op landing the file, and watchOp follows that op to its end. +// +// The session is also remembered in this browser, under the server and path it +// lands at, with the file's size and modification time. Choosing the same file +// again after a reload, a closed tab or a lost connection carries on from the +// bytes already sent. A remembered session no page has touched for a while is +// what a refusal for too many sessions gives back first. + +type Sleep = (ms: number, signal?: AbortSignal) => Promise; + +/** How often a running op is read. */ +export const OP_POLL_MS = 2000; +/** Reads of the ops list that may miss an op before it counts as lost. */ +export const MAX_OP_MISSES = 5; +/** How long a remembered session must sit untouched before it is given back + * to make room for a new one; one another tab is still sending is younger. */ +export const STALE_SESSION_MS = 5 * 60 * 1000; + +const KEY_PREFIX = "felis-file-upload:"; + +interface Remembered { + server: string; + path: string; + id: string; + size: number; + modified: number; + touched: number; +} + +const code = (e: unknown) => (e as { code?: unknown } | null)?.code; + +// Browser storage can be missing or refuse (a private window, blocked site +// data); a session is then simply not remembered. +function storage(): Storage | null { + try { + return window.localStorage; + } catch { + return null; + } +} + +const keyOf = (server: string, path: string) => `${KEY_PREFIX}${server}:${path}`; + +function remember(r: Remembered) { + try { + storage()?.setItem(keyOf(r.server, r.path), JSON.stringify(r)); + } catch { + /* not remembered: a reload starts this file over */ + } +} + +function recalled(server: string, path: string, file: File): Remembered | null { + try { + const raw = storage()?.getItem(keyOf(server, path)); + const r = raw ? (JSON.parse(raw) as Remembered) : null; + return r && r.id && r.size === file.size && r.modified === file.lastModified ? r : null; + } catch { + return null; + } +} + +function rememberedAll(): Remembered[] { + const s = storage(); + const out: Remembered[] = []; + try { + for (let i = 0; s && i < s.length; i++) { + const k = s.key(i); + if (!k?.startsWith(KEY_PREFIX)) continue; + const r = JSON.parse(s.getItem(k) ?? "null") as Remembered | null; + if (r?.id && r.server && typeof r.path === "string") out.push(r); + } + } catch { + /* whatever was read so far */ + } + return out; +} + +/** forgetSession stops remembering the session for path on server, once its + * file has landed. */ +export function forgetSession(server: string, path: string) { + try { + storage()?.removeItem(keyOf(server, path)); + } catch { + /* nothing to forget */ + } +} + +/** discardSession cancels a session and forgets it. A part still arriving holds + * it briefly (upload_busy), so that is waited out a few times; whatever still + * fails is left for the server to drop after 6 idle hours. */ +export async function discardSession(server: string, path: string, id: string, sleep: Sleep = wait) { + forgetSession(server, path); + for (let n = 1; n <= 3; n++) { + try { + await api.deleteServerFileUpload(server, id); + return; + } catch (e) { + if (code(e) !== "upload_busy") return; + await sleep(retryDelay(n)); + } + } +} + +// dropStale cancels the remembered sessions nothing has sent a part to lately +// and reports whether any of them was still held. +async function dropStale(now: number): Promise { + let freed = false; + for (const r of rememberedAll()) { + if (now - r.touched < STALE_SESSION_MS) continue; + forgetSession(r.server, r.path); + try { + await api.deleteServerFileUpload(r.server, r.id); + freed = true; + } catch { + /* gone already, or someone else's */ + } + } + return freed; +} + +export interface SendOptions { + overwrite: boolean; + /** Bytes of the file the server holds so far, plus the part in flight. */ + onProgress?: (sent: number) => void; + /** The session the file goes up in, once there is one. */ + onSession?: (id: string) => void; + /** Stops sending; the session stays, remembered, for a later resume. */ + signal?: AbortSignal; + /** Test seam for the pause between attempts. */ + sleep?: Sleep; + /** Test seam for the clock the remembered sessions are aged by. */ + now?: () => number; +} + +/** sendInParts sends file to path on server as an upload session, commits it, + * and answers the op landing it. */ +export async function sendInParts(server: string, path: string, file: File, opts: SendOptions): Promise { + const { signal, onProgress } = opts; + const sleep = opts.sleep ?? wait; + const now = opts.now ?? Date.now; + const size = file.size; + + let id: string | null = recalled(server, path, file)?.id ?? null; + // offset is where the next part starts; null means "ask the server first". + let offset: number | null = null; + let partMax = 0; + let failures = 0; + for (;;) { + try { + if (offset === null) { + const at = await standing(server, path, file, id, now); + id = at.id; + opts.onSession?.(id); + partMax = at.part_max_bytes; + offset = at.received; + onProgress?.(offset); + } + if (offset >= size) break; + const start = offset; + const part = file.slice(start, Math.min(start + partMax, size)); + const at = await api.putServerFileUploadPart(server, id!, start, part, { + signal, + onProgress: (sent) => onProgress?.(start + sent), + }); + offset = at.received; + failures = 0; + remember({ server, path, id: id!, size, modified: file.lastModified, touched: now() }); + onProgress?.(offset); + } catch (e) { + // The session went away under the upload (felis-api restarted, or it sat + // idle too long): the next pass begins a new one. + if (code(e) === "upload_not_found") { + id = null; + } else if (!isTransient(e)) { + throw e; + } + if (++failures >= MAX_ATTEMPTS) throw e; + await sleep(retryDelay(failures), signal); + offset = null; + } + } + return commit(server, path, id!, opts.overwrite, sleep, signal); +} + +// standing answers the session to carry on with: id's when it still stands for +// this file, else a new one. Four sessions per account is the server's bound; +// when that refuses, the sessions this browser left behind are given back +// first. +async function standing( + server: string, + path: string, + file: File, + id: string | null, + now: () => number, +): Promise { + if (id) { + try { + const at = await api.getServerFileUpload(server, id); + if (at.path === path && at.size === file.size) return at; + } catch (e) { + if (code(e) !== "upload_not_found") throw e; + } + } + let at: FileUploadSession; + try { + at = await api.beginServerFileUpload(server, path, file.size); + } catch (e) { + if (code(e) !== "too_many_uploads" || !(await dropStale(now()))) throw e; + at = await api.beginServerFileUpload(server, path, file.size); + } + remember({ server, path, id: at.id, size: file.size, modified: file.lastModified, touched: now() }); + return at; +} + +// commit lands the session. A commit whose answer was lost may still have +// started the Job, so asking again is read in that light: the world held +// (maintenance_in_progress) by an upload of this path running now, or the +// session gone because that Job already fetched it, means the first commit +// went through, and its op is the answer. +async function commit( + server: string, + path: string, + id: string, + overwrite: boolean, + sleep: Sleep, + signal?: AbortSignal, +): Promise { + let lost = false; + for (let failures = 1; ; failures++) { + try { + return (await api.commitServerFileUpload(server, id, overwrite)).op; + } catch (e) { + const c = code(e); + if (lost && (c === "maintenance_in_progress" || c === "upload_not_found")) { + const { ops } = await api.listServerFileOps(server); + const op = ops.find( + (o) => o.op === "upload" && o.path === path && (c === "upload_not_found" || o.state === "running"), + ); + if (op) return op; + if (c === "upload_not_found") throw e; + } else if (isTransient(e)) { + lost = true; + } else { + throw e; + } + if (failures >= MAX_ATTEMPTS) throw e; + await sleep(retryDelay(failures), signal); + } + } +} + +export interface WatchOptions { + signal?: AbortSignal; + sleep?: Sleep; + /** Each read of the op while it runs. */ + onProgress?: (op: FileOp) => void; +} + +/** watchOp reads server's ops until the op id has ended, and answers it. A read + * that did not get through is tried again, backing off; an op missing from + * MAX_OP_MISSES reads in a row is reported as op_lost. */ +export async function watchOp(server: string, id: string, opts: WatchOptions = {}): Promise { + const sleep = opts.sleep ?? wait; + let failures = 0; + let misses = 0; + for (;;) { + await sleep(failures > 0 ? retryDelay(failures) : OP_POLL_MS, opts.signal); + let ops: FileOp[]; + try { + ops = (await api.listServerFileOps(server)).ops; + failures = 0; + } catch (e) { + if (!isTransient(e) || ++failures >= MAX_ATTEMPTS) throw e; + continue; + } + const op = ops.find((o) => o.id === id); + if (!op) { + if (++misses >= MAX_OP_MISSES) throw clientError("op_lost"); + continue; + } + misses = 0; + if (op.state !== "running") return op; + opts.onProgress?.(op); + } +} diff --git a/panel/src/components/files/useFileOps.test.tsx b/panel/src/components/files/useFileOps.test.tsx new file mode 100644 index 0000000..995e79b --- /dev/null +++ b/panel/src/components/files/useFileOps.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import type { FileOp } from "@/lib/types"; +import { OP_POLL_MS } from "./sessionUpload"; +import { useFileOps } from "./useFileOps"; + +const mocks = vi.hoisted(() => ({ listServerFileOps: vi.fn() })); + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, ...mocks } }; +}); + +function op(id: string, over: Partial = {}): FileOp { + return { id, op: "unzip", path: `${id}.zip`, state: "running", started_at: "2026-09-28T00:00:00Z", done: 0, total: 0, ...over }; +} + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((res) => (resolve = res)); + return { promise, resolve }; +} + +const ids = (ops: readonly FileOp[]) => ops.map((o) => `${o.id}:${o.state}`); +const tick = () => act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + +beforeEach(() => { + mocks.listServerFileOps.mockReset(); + vi.useFakeTimers(); +}); +afterEach(() => vi.useRealTimers()); + +describe("useFileOps", () => { + it("reads nothing until enabled, then once, and polls no further while nothing runs", async () => { + mocks.listServerFileOps.mockResolvedValue({ ops: [op("a", { state: "succeeded" })] }); + const { result, rerender } = renderHook(({ on }) => useFileOps("lobby", on, () => {}), { initialProps: { on: false } }); + await tick(); + expect(mocks.listServerFileOps).not.toHaveBeenCalled(); + + rerender({ on: true }); + await act(async () => {}); + expect(mocks.listServerFileOps.mock.calls).toEqual([["lobby"]]); + expect(ids(result.current.ops)).toEqual(["a:succeeded"]); + expect(result.current.running).toBe(false); + + await tick(); + await tick(); + expect(mocks.listServerFileOps).toHaveBeenCalledTimes(1); + }); + + it("polls while one runs, and tells of each op it saw running once that op ends", async () => { + const ended = vi.fn(); + mocks.listServerFileOps + .mockResolvedValueOnce({ ops: [op("a", { done: 1, total: 4 }), op("old", { state: "failed" })] }) + .mockResolvedValueOnce({ ops: [op("a", { done: 3, total: 4 }), op("old", { state: "failed" })] }) + .mockResolvedValue({ ops: [op("a", { state: "succeeded" }), op("old", { state: "failed" })] }); + const { result } = renderHook(() => useFileOps("lobby", true, ended)); + await act(async () => {}); + expect(result.current.running).toBe(true); + + await tick(); + expect(result.current.ops[0].done).toBe(3); + expect(ended).not.toHaveBeenCalled(); + + await tick(); + expect(result.current.running).toBe(false); + // Only the op seen running here: "old" had ended before the page looked. + expect(ended.mock.calls.map(([o]) => o.id)).toEqual(["a"]); + + await tick(); + await tick(); + expect(mocks.listServerFileOps).toHaveBeenCalledTimes(3); + expect(ended).toHaveBeenCalledTimes(1); + }); + + it("shows an op this page started at once, over a read already on its way", async () => { + const slow = deferred<{ ops: FileOp[] }>(); + mocks.listServerFileOps.mockReturnValueOnce(slow.promise); + const { result } = renderHook(() => useFileOps("lobby", true, () => {})); + + act(() => result.current.started(op("new"))); + expect(ids(result.current.ops)).toEqual(["new:running"]); + await act(async () => slow.resolve({ ops: [] })); + + expect(ids(result.current.ops)).toEqual(["new:running"]); + expect(result.current.running).toBe(true); + }); + + it("tells of the end of an op this page started even when no read saw it running", async () => { + const ended = vi.fn(); + mocks.listServerFileOps.mockResolvedValueOnce({ ops: [] }).mockResolvedValue({ ops: [op("new", { state: "failed" })] }); + const { result } = renderHook(() => useFileOps("lobby", true, ended)); + await act(async () => {}); + + act(() => result.current.started(op("new"))); + await tick(); + + expect(ended.mock.calls.map(([o]) => `${o.id}:${o.state}`)).toEqual(["new:failed"]); + }); + + it("starts no read over one still on its way", async () => { + const slow = deferred<{ ops: FileOp[] }>(); + mocks.listServerFileOps.mockResolvedValueOnce({ ops: [op("a")] }).mockReturnValueOnce(slow.promise); + renderHook(() => useFileOps("lobby", true, () => {})); + await act(async () => {}); + + await tick(); + await tick(); + await tick(); + + expect(mocks.listServerFileOps).toHaveBeenCalledTimes(2); + }); + + it("keeps an ignored op out from then on, and never tells of its end", async () => { + const ended = vi.fn(); + mocks.listServerFileOps + .mockResolvedValueOnce({ ops: [op("mine", { op: "upload" }), op("theirs")] }) + .mockResolvedValue({ ops: [op("mine", { op: "upload", state: "succeeded" }), op("theirs", { state: "succeeded" })] }); + const { result } = renderHook(() => useFileOps("lobby", true, ended)); + await act(async () => {}); + + act(() => result.current.ignore("mine")); + expect(ids(result.current.ops)).toEqual(["theirs:running"]); + await tick(); + + expect(ids(result.current.ops)).toEqual(["theirs:succeeded"]); + expect(ended.mock.calls.map(([o]) => o.id)).toEqual(["theirs"]); + }); + + it("an ignored op that runs alone holds nothing", async () => { + mocks.listServerFileOps.mockResolvedValue({ ops: [op("mine", { op: "upload" })] }); + const { result } = renderHook(() => useFileOps("lobby", true, () => {})); + await act(async () => {}); + expect(result.current.running).toBe(true); + + act(() => result.current.ignore("mine")); + + expect(result.current.running).toBe(false); + }); + + it("hides a dismissed op", async () => { + mocks.listServerFileOps.mockResolvedValue({ ops: [op("a", { state: "failed" }), op("b", { state: "succeeded" })] }); + const { result } = renderHook(() => useFileOps("lobby", true, () => {})); + await act(async () => {}); + + act(() => result.current.dismiss("a")); + + expect(ids(result.current.ops)).toEqual(["b:succeeded"]); + }); + + it("says why a read failed, and clears it once one gets through", async () => { + const refused = { status: 500, code: "internal", message: "" }; + mocks.listServerFileOps.mockRejectedValueOnce(refused).mockResolvedValueOnce({ ops: [] }); + const { result } = renderHook(() => useFileOps("lobby", true, () => {})); + await act(async () => {}); + expect(result.current.error).toBe(refused); + + await act(async () => result.current.refresh()); + + expect(result.current.error).toBeNull(); + }); +}); diff --git a/panel/src/components/files/useFileOps.ts b/panel/src/components/files/useFileOps.ts new file mode 100644 index 0000000..c7ae2d9 --- /dev/null +++ b/panel/src/components/files/useFileOps.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "@/lib/api"; +import { usePolling } from "@/lib/hooks"; +import type { FileOp } from "@/lib/types"; +import { OP_POLL_MS } from "./sessionUpload"; + +// useFileOps follows a server's background ops (an extraction, or the landing of +// a file sent in parts): read once when `enabled` turns on, then again every +// OP_POLL_MS while one runs. They carry on with the page closed, so a visit +// after one began still shows it and how it ended. onEnded runs once for each op +// seen running here that has since ended. +export function useFileOps(server: string, enabled: boolean, onEnded: (op: FileOp) => void) { + const [ops, setOps] = useState([]); + const [error, setError] = useState(null); + const [dismissed, setDismissed] = useState>(new Set()); + const seenRunning = useRef(new Set()); + const ended = useRef(onEnded); + ended.current = onEnded; + // Only the newest read lands, and a read is not started over one in flight: + // a slow answer must not undo a newer one, nor pile up behind the interval. + const seq = useRef(0); + const inFlight = useRef(false); + // Ops the upload queue follows itself (the landing of a file it sent in + // parts): its own row shows them, so they are left out here. + const ignored = useRef(new Set()); + + const read = useCallback(async () => { + if (inFlight.current) return; + inFlight.current = true; + const ticket = ++seq.current; + try { + const r = await api.listServerFileOps(server); + if (ticket !== seq.current) return; + const all = (r.ops ?? []).filter((op) => !ignored.current.has(op.id)); + setError(null); + setOps(all); + for (const op of all) { + if (op.state === "running") seenRunning.current.add(op.id); + else if (seenRunning.current.delete(op.id)) ended.current(op); + } + } catch (e) { + if (ticket === seq.current) setError(e); + } finally { + inFlight.current = false; + } + }, [server]); + + useEffect(() => { + if (enabled) void read(); + }, [enabled, read]); + + const running = ops.some((op) => op.state === "running"); + const poll = useCallback(() => void read(), [read]); + usePolling(poll, enabled && running ? OP_POLL_MS : null); + + /** started shows an op this page just began at once, ahead of the next read, + * and watches it from there. */ + const started = useCallback((op: FileOp) => { + if (op.state === "running") seenRunning.current.add(op.id); + seq.current++; // a read already in flight predates it + setOps((all) => [op, ...all.filter((o) => o.id !== op.id)]); + }, []); + + const dismiss = useCallback((id: string) => setDismissed((s) => new Set(s).add(id)), []); + /** ignore leaves the op id out from now on. */ + const ignore = useCallback((id: string) => { + ignored.current.add(id); + setOps((all) => all.filter((o) => o.id !== id)); + }, []); + + return { ops: ops.filter((op) => !dismissed.has(op.id)), running, error, refresh: poll, started, dismiss, ignore }; +} diff --git a/panel/src/components/files/useUploads.ts b/panel/src/components/files/useUploads.ts index a0084f4..337b71f 100644 --- a/panel/src/components/files/useUploads.ts +++ b/panel/src/components/files/useUploads.ts @@ -2,12 +2,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import i18next from "i18next"; import { api, humanizeError } from "@/lib/api"; import { formatBytes } from "@/lib/format"; -import type { ServerFileEntry } from "@/lib/types"; +import type { FileOpError, ServerFileEntry } from "@/lib/types"; import { joinPath } from "./names"; +import { opErrorText } from "./opText"; +import { discardSession, forgetSession, sendInParts, watchOp } from "./sessionUpload"; -/** The upload ceiling, mirrored from fileedit.MaxUploadBytes (server truth, 413 - * above it). Checked here so a file too large is refused before it is sent. */ -export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024; +/** The most one upload request carries, mirrored from fileedit.MaxUploadBytes + * (413 above it). A larger file goes up in parts instead (sessionUpload.ts), + * with no ceiling but the room on the world volume. */ +export const ONE_REQUEST_BYTES = 64 * 1024 * 1024; /** queued waits its turn; exists found a file already at its path and waits for * replace or skip; failed stays until it is retried or dismissed. */ @@ -22,21 +25,50 @@ export interface UploadItem { sent: number; overwrite: boolean; error: string | null; - /** false for a refusal sending again cannot change (too large, a folder there). */ + /** false for a refusal sending again cannot change (a folder there). */ retryable: boolean; + /** How far the Job landing a file sent in parts has got, once it reports. */ + landing: { done: number; total: number } | null; +} + +/** An op that ended failed, thrown so the queue settles it like any refusal. */ +interface OpFailure { + status: 0; + code: string; + message: string; + opError: FileOpError; +} + +interface Options { + /** Runs after each upload that landed, with the folder it landed in. */ + onLanded: (dir: string) => void; + /** Runs with the id of each op a file sent in parts is landed by. */ + onOp?: (id: string) => void; + /** Holds the queue: nothing new starts while it is true. */ + hold?: boolean; + /** Bytes free on the world volume, from the latest listing. */ + free?: number | null; } // useUploads runs a queue of uploads into a server's world volume, one at a time: // each is a file Job that holds the world lock, so a second one sent alongside -// would only be refused with 409 maintenance_in_progress. onLanded runs after -// each upload that landed, with the folder it landed in. -export function useUploads(server: string, onLanded: (dir: string) => void) { +// would only be refused with 409 maintenance_in_progress. +export function useUploads(server: string, { onLanded, onOp, hold = false, free = null }: Options) { const [items, setItems] = useState([]); + const current = useRef(items); + current.current = items; const nextId = useRef(1); const running = useRef(null); const abort = useRef(null); - const landed = useRef(onLanded); - landed.current = onLanded; + // An upload cancelled on purpose gives its session back; one stopped because + // the page went away keeps it for a resume. + const cancelled = useRef(null); + // The session each file sent in parts went up in, by item. + const sessions = useRef(new Map()); + const hooks = useRef({ onLanded, onOp }); + hooks.current = { onLanded, onOp }; + const room = useRef(free); + room.current = free; const patch = useCallback((id: number, change: Partial) => { setItems((all) => all.map((it) => (it.id === id ? { ...it, ...change } : it))); @@ -44,81 +76,171 @@ export function useUploads(server: string, onLanded: (dir: string) => void) { const drop = useCallback((id: number) => { setItems((all) => all.filter((it) => it.id !== id)); }, []); + // discard gives back the session an item holds, if any. + const discard = useCallback( + (id: number) => { + const s = sessions.current.get(id); + sessions.current.delete(id); + if (s) void discardSession(server, s.path, s.id); + }, + [server], + ); // Leaving the page stops the upload in flight; the queued ones were never sent. useEffect(() => () => abort.current?.abort(), []); + // sendLarge sends a file in parts and follows the op landing it to its end. + const sendLarge = useCallback( + async (it: UploadItem, path: string, signal: AbortSignal) => { + const op = await sendInParts(server, path, it.file, { + overwrite: it.overwrite, + signal, + onProgress: (sent) => patch(it.id, { sent }), + onSession: (id) => sessions.current.set(it.id, { path, id }), + }); + hooks.current.onOp?.(op.id); + patch(it.id, { sent: it.file.size }); + const end = + op.state === "running" + ? await watchOp(server, op.id, { + signal, + onProgress: (o) => patch(it.id, { landing: { done: o.done, total: o.total } }), + }) + : op; + if (end.state === "failed") { + const opError = end.error ?? { code: "job_failed", message: "" }; + const failure: OpFailure = { status: 0, code: opError.code, message: opError.message, opError }; + throw failure; + } + sessions.current.delete(it.id); + forgetSession(server, path); + }, + [server, patch], + ); + useEffect(() => { - if (running.current !== null) return; + if (hold || running.current !== null) return; const next = items.find((it) => it.state === "queued"); if (!next) return; running.current = next.id; const ctrl = new AbortController(); abort.current = ctrl; - patch(next.id, { state: "uploading", sent: 0, error: null }); + patch(next.id, { state: "uploading", sent: 0, error: null, landing: null }); const settle = () => { running.current = null; abort.current = null; }; - api - .uploadServerFile(server, joinPath(next.dir, next.file.name), next.file, next.overwrite, { - signal: ctrl.signal, - onProgress: (sent) => patch(next.id, { sent }), - }) - .then( - () => { - settle(); - patch(next.id, { state: "done", sent: next.file.size }); - landed.current(next.dir); - }, - (e: unknown) => { - settle(); - if (e instanceof DOMException && e.name === "AbortError") { - drop(next.id); - } else if ((e as { code?: string }).code === "file_exists") { - // Someone put a file there since the listing: ask, as for one listed. - patch(next.id, { state: "exists", sent: 0 }); - } else { - patch(next.id, { state: "failed", sent: 0, error: humanizeError(e), retryable: true }); - } - }, - ); - }, [items, server, patch, drop]); + const path = joinPath(next.dir, next.file.name); + const sending = + next.file.size > ONE_REQUEST_BYTES + ? sendLarge(next, path, ctrl.signal) + : api.uploadServerFile(server, path, next.file, next.overwrite, { + signal: ctrl.signal, + onProgress: (sent) => patch(next.id, { sent }), + }); + sending.then( + () => { + settle(); + patch(next.id, { state: "done", sent: next.file.size }); + hooks.current.onLanded(next.dir); + }, + (e: unknown) => { + settle(); + if (e instanceof DOMException && e.name === "AbortError") { + if (cancelled.current === next.id) discard(next.id); + cancelled.current = null; + drop(next.id); + } else if ((e as { code?: string }).code === "file_exists") { + // Someone put a file there since the listing: ask, as for one listed. + // A file sent in parts keeps its session, so replacing it lands the + // bytes already sent. + patch(next.id, { state: "exists", sent: 0, landing: null }); + } else { + const opError = (e as Partial).opError; + patch(next.id, { + state: "failed", + sent: 0, + landing: null, + error: opError ? opErrorText("upload", opError) : humanizeError(e), + retryable: true, + }); + } + }, + ); + }, [items, hold, server, patch, drop, discard, sendLarge]); /** add queues files for dir. `entries` is dir's listing: a file of the same - * name there waits for replace or skip instead of being sent to be refused. */ + * name there waits for replace or skip instead of being sent to be refused. + * Files the world volume has no room for are refused before any is sent, + * counting the ones ahead of them in the same batch. */ const add = useCallback((files: readonly File[], dir: string, entries: readonly ServerFileEntry[] | null) => { const t = i18next.getFixedT(null, "files"); + let budget = room.current; const added = files.map((file): UploadItem => { - const base = { id: nextId.current++, file, dir, sent: 0, overwrite: false, error: null, retryable: false }; + const base = { + id: nextId.current++, + file, + dir, + sent: 0, + overwrite: false, + error: null, + retryable: false, + landing: null, + }; const there = entries?.find((e) => e.name === file.name); - if (file.size > MAX_UPLOAD_BYTES) { - return { ...base, state: "failed", error: t("upload_too_large", { limit: formatBytes(MAX_UPLOAD_BYTES) }) }; - } if (there?.is_dir) { return { ...base, state: "failed", error: t("upload_folder_there") }; } + if (budget !== null && file.size > budget) { + return { ...base, state: "failed", error: noRoom(file, budget), retryable: true }; + } + if (budget !== null) budget -= file.size; return { ...base, state: there ? "exists" : "queued" }; }); setItems((all) => [...all, ...added]); }, []); - const replace = useCallback((id: number) => patch(id, { state: "queued", overwrite: true }), [patch]); - const retry = useCallback((id: number) => patch(id, { state: "queued", error: null }), [patch]); + // requeue sends the items picked again once the volume has room for each by + // the latest listing; one it has none for stays failed and says so again. + const requeue = useCallback((pick: (it: UploadItem) => boolean, change: Partial) => { + const free = room.current; + setItems((all) => + all.map((it) => { + if (!pick(it)) return it; + if (free !== null && it.file.size > free) { + return { ...it, state: "failed", error: noRoom(it.file, free), retryable: true }; + } + return { ...it, ...change, state: "queued", error: null }; + }), + ); + }, []); + + const replace = useCallback((id: number) => requeue((it) => it.id === id, { overwrite: true }), [requeue]); + const retry = useCallback((id: number) => requeue((it) => it.id === id, {}), [requeue]); /** remove cancels an upload in flight, or takes any other one off the list. */ const remove = useCallback( (id: number) => { - if (running.current === id) abort.current?.abort(); - else drop(id); + if (running.current === id) { + cancelled.current = id; + abort.current?.abort(); + } else { + discard(id); + drop(id); + } }, - [drop], + [drop, discard], ); - const replaceAll = useCallback(() => { - setItems((all) => all.map((it) => (it.state === "exists" ? { ...it, state: "queued", overwrite: true } : it))); - }, []); - const skipAll = useCallback(() => setItems((all) => all.filter((it) => it.state !== "exists")), []); + const replaceAll = useCallback(() => requeue((it) => it.state === "exists", { overwrite: true }), [requeue]); + const skipAll = useCallback(() => { + for (const it of current.current) if (it.state === "exists") discard(it.id); + setItems((all) => all.filter((it) => it.state !== "exists")); + }, [discard]); const clearDone = useCallback(() => setItems((all) => all.filter((it) => it.state !== "done")), []); const busy = items.some((it) => it.state === "queued" || it.state === "uploading"); return { items, busy, add, replace, retry, remove, replaceAll, skipAll, clearDone }; } + +function noRoom(file: File, free: number): string { + return i18next.t("files:upload_no_room", { free: formatBytes(free), size: formatBytes(file.size) }); +} diff --git a/panel/src/i18n/resources/en-US/backups.json b/panel/src/i18n/resources/en-US/backups.json index fefe57e..7c66053 100644 --- a/panel/src/i18n/resources/en-US/backups.json +++ b/panel/src/i18n/resources/en-US/backups.json @@ -43,6 +43,7 @@ "job_scheduled": "Scheduled backup", "job_export_world": "World export", "job_export_backup": "Backup download", + "job_export_files": "File download", "job_running": "Running", "job_succeeded": "Succeeded", "job_failed": "Failed", diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 8bb318c..0850b3e 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -71,6 +71,9 @@ "upload_staging_full": "The panel's upload space is nearly full right now, so the file was not passed on. Try again later, or ask an admin to free space on the uploads volume.", "upload_incomplete": "The upload stopped before the whole file arrived, so nothing was changed. Try again.", "length_required": "The upload did not say how large it is, so it was refused. Upload it again from the panel.", + "upload_not_found": "This upload is gone: it was cancelled, already landed, sat idle for 6 hours, or the panel service restarted. Upload the file again.", + "too_many_uploads": "You already have 4 large uploads in progress. Wait for one to finish, or cancel one, and try again.", + "op_lost": "The operation's progress can no longer be read. Refresh the list to see whether the file landed.", "jobs_unavailable": "The background job service isn't available right now.", "already_terminal": "This build already finished — there is nothing to cancel.", "build_unavailable": "Image builds aren't available right now.", @@ -82,7 +85,7 @@ "submission_cooldown": "Too many submission requests — try again shortly.", "submissions_unavailable": "Submissions aren't available right now.", "uploads_unavailable": "Uploads aren't available right now.", - "upload_busy": "Another upload of this submission is still running — wait a moment and try again.", + "upload_busy": "Another part of this upload is still being sent — wait a moment and try again.", "upload_offset_mismatch": "The upload fell out of step with the server — try again to pick up where it stopped.", "uploads_store_unavailable": "The uploads store did not answer — try again in a moment; what was already sent is kept.", "part_too_large": "A piece of the upload was larger than the server accepts.", diff --git a/panel/src/i18n/resources/en-US/files.json b/panel/src/i18n/resources/en-US/files.json index 511c482..845239d 100644 --- a/panel/src/i18n/resources/en-US/files.json +++ b/panel/src/i18n/resources/en-US/files.json @@ -35,7 +35,7 @@ "new_file": "New file", "new_folder": "New folder", "upload": "Upload", - "upload_hint": "Upload files into this folder (up to {{limit}} each), or drop them onto the list", + "upload_hint": "Upload files into this folder, or drop them on the list. Zip a folder before uploading it", "in_folder": "In {{dir}}", "name_label": "Name", "create": "Create", @@ -60,8 +60,8 @@ "delete_folder_body": "The folder and everything in it are deleted for good. Take a backup first if you may want any of it back.", "secret_config_unreadable": "config/paper-global.yml holds the proxy forwarding secret every server shares, so the editor does not open it.", "drop_here": "Drop to upload into {{dir}}", - "upload_no_folders_one": "A folder cannot be uploaded, so it was skipped. Make the folder here, then upload the files inside it.", - "upload_no_folders_other": "Folders cannot be uploaded, so {{count}} were skipped. Make the folders here, then upload the files inside them.", + "upload_no_folders_one": "Folders cannot be uploaded, so one was skipped: loose files cut off halfway leave a broken save behind. Zip it, upload the .zip, then choose Extract here on it.", + "upload_no_folders_other": "Folders cannot be uploaded, so {{count}} were skipped: loose files cut off halfway leave a broken save behind. Zip them, upload the .zip, then choose Extract here on it.", "uploads_label": "Uploads", "uploads_title": "Uploads · {{done}} of {{total}} done", "upload_queued": "Waiting", @@ -78,6 +78,48 @@ "upload_dismiss": "Dismiss {{name}}", "upload_clear_done": "Clear finished", "upload_progress_label": "Uploading {{name}}", - "upload_too_large": "Larger than the {{limit}} upload limit.", - "upload_folder_there": "A folder with this name is already here." + "upload_folder_there": "A folder with this name is already here.", + "upload_no_room": "The world volume has {{free}} free, not enough for this {{size}} file. Delete files you do not need, then retry.", + "upload_landing_progress": "Writing it to the server… {{percent}}%", + "wait_for_op": "Wait for the background operation to finish first.", + "wait_for_download": "Wait until the download is ready first.", + "unzip_item": "Extract {{name}} here", + "unzip_conflicts_title_one": "Extracting {{name}} replaces {{count}} file", + "unzip_conflicts_title_other": "Extracting {{name}} replaces {{count}} files", + "unzip_conflicts_body": "These files in the archive are already here. Confirm to replace them with the archive's versions; everything else extracts as usual. Take a backup first if you may want the old versions back.", + "unzip_conflicts_more": "{{count}} more not listed.", + "unzip_overwrite": "Replace and extract", + "download_item": "Download {{name}}", + "download_folder_item": "Download the folder {{name}} as a .zip", + "download_preparing": "Preparing the download of {{name}}…", + "download_started": "Downloading {{filename}}.", + "download_started_props": "Downloading {{filename}}. Its rcon.password is redacted.", + "download_started_config": "Downloading {{filename}}. paper-global.yml, the proxy forwarding secret every server shares, is left out.", + "download_failed": "The download could not be prepared.", + "download_failed_because": "The download could not be prepared: {{reason}}", + "download_busy": "Too many file downloads are being prepared (two at a time per person, thirty an hour). Try again in a few minutes.", + "secret_config_no_download": "config/paper-global.yml holds the proxy forwarding secret every server shares, so it cannot be downloaded.", + "ops_label": "Background operations", + "ops_refresh_failed": "Could not read the background operations: {{reason}}", + "op_unzip": "Extract {{path}}", + "op_upload": "Write {{path}}", + "op_preparing": "Getting ready…", + "op_progress": "{{done}} of {{total}} · {{percent}}%", + "op_progress_label": "Progress of {{path}}", + "op_unzip_done_one": "Extracted {{count}} file, {{bytes}} in all.", + "op_unzip_done_other": "Extracted {{count}} files, {{bytes}} in all.", + "op_upload_done": "Written.", + "op_dismiss": "Dismiss the result for {{path}}", + "op_conflicts": "Review conflicts", + "op_upload_exists": "A file with this name is already here, so nothing was replaced. Upload it again and choose Replace.", + "op_unzip_conflicts_one": "It would replace {{count}} file already here, so nothing was extracted yet.", + "op_unzip_conflicts_other": "It would replace {{count}} files already here, so nothing was extracted yet.", + "op_volume_full": "Not enough room on the world volume: {{need}} needed, {{avail}} free. Nothing was changed.", + "archive_invalid": "The archive is damaged, or not a zip file. Nothing was changed.", + "archive_invalid_entry": "{{entry}} in the archive is damaged (its size or checksum does not match). Nothing was changed.", + "archive_unsafe": "{{entry}} in the archive would land outside this folder, or is a device file. Nothing in the archive was extracted.", + "archive_symlink": "{{entry}} in the archive is a symbolic link. Nothing in the archive was extracted.", + "type_conflict": "{{entry}} is a file on one side and a folder on the other, which replacing cannot resolve. Rename or delete {{entry}} here, then extract again.", + "job_failed": "The background task ended without saying why. Refresh the list to check, then try again.", + "job_timed_out": "The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again." } diff --git a/panel/src/i18n/resources/zh-CN/backups.json b/panel/src/i18n/resources/zh-CN/backups.json index 1932021..0fd1d6c 100644 --- a/panel/src/i18n/resources/zh-CN/backups.json +++ b/panel/src/i18n/resources/zh-CN/backups.json @@ -43,6 +43,7 @@ "job_scheduled": "定时备份", "job_export_world": "世界导出", "job_export_backup": "备份下载", + "job_export_files": "文件下载", "job_running": "进行中", "job_succeeded": "成功", "job_failed": "失败", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 01e73ed..106a3b8 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -71,6 +71,9 @@ "upload_staging_full": "面板的上传暂存空间快满了,这个文件没有转存过去。稍后再试,或者请管理员清理上传卷。", "upload_incomplete": "文件还没传完上传就中断了,什么都没有改动。请重试。", "length_required": "这次上传没有声明文件大小,被拒绝了。请从面板重新上传。", + "upload_not_found": "这次分片上传已经不在了(取消过、已经写入、闲置超过 6 小时,或者面板服务重启过)。请重新上传。", + "too_many_uploads": "你同时进行的大文件上传已经有 4 个了。等其中一个完成,或者取消一个再试。", + "op_lost": "看不到这次操作的进度了。刷新列表看看文件有没有写入。", "jobs_unavailable": "后台任务服务当前不可用。", "already_terminal": "该构建已经结束,无法重复取消。", "build_unavailable": "构建功能当前不可用。", @@ -82,7 +85,7 @@ "submission_cooldown": "操作太频繁——请稍后再试。", "submissions_unavailable": "提交流程当前不可用。", "uploads_unavailable": "上传功能当前不可用。", - "upload_busy": "这个投稿的另一次上传仍在进行——请稍等片刻再试。", + "upload_busy": "这次上传的另一部分仍在发送——请稍等片刻再试。", "upload_offset_mismatch": "上传进度与服务器对不上——重试即可从中断处接着传。", "uploads_store_unavailable": "上传存储暂时没有响应——稍后重试即可,已传的部分会保留。", "part_too_large": "上传的某一片超过了服务器接受的大小。", diff --git a/panel/src/i18n/resources/zh-CN/files.json b/panel/src/i18n/resources/zh-CN/files.json index a9d05fa..b99c13b 100644 --- a/panel/src/i18n/resources/zh-CN/files.json +++ b/panel/src/i18n/resources/zh-CN/files.json @@ -35,7 +35,7 @@ "new_file": "新建文件", "new_folder": "新建文件夹", "upload": "上传", - "upload_hint": "上传文件到这个文件夹(每个最大 {{limit}}),也可以直接拖到列表上", + "upload_hint": "上传文件到这个文件夹,也可以直接拖到列表上。文件夹请先压成 .zip 再上传", "in_folder": "位置:{{dir}}", "name_label": "名称", "create": "创建", @@ -60,7 +60,7 @@ "delete_folder_body": "文件夹和里面的所有内容都会被永久删除。之后可能还要用的话,先做一次备份。", "secret_config_unreadable": "config/paper-global.yml 里有所有服务器共用的代理转发密钥,编辑器不打开它。", "drop_here": "松开即可上传到 {{dir}}", - "upload_no_folders": "文件夹没法直接上传,已跳过 {{count}} 个。先在这里新建同名文件夹,再上传里面的文件。", + "upload_no_folders": "不支持上传文件夹(已跳过 {{count}} 个):散文件传到一半断掉会留下残缺存档。请先压成 .zip 上传,再对它点「解压到此处」。", "uploads_label": "上传", "uploads_title": "上传 · 已完成 {{done}}/{{total}}", "upload_queued": "等待中", @@ -77,6 +77,45 @@ "upload_dismiss": "移除 {{name}}", "upload_clear_done": "清除已完成", "upload_progress_label": "正在上传 {{name}}", - "upload_too_large": "超过 {{limit}} 的上传上限。", - "upload_folder_there": "这里已经有同名文件夹。" + "upload_folder_there": "这里已经有同名文件夹。", + "upload_no_room": "世界卷只剩 {{free}},放不下这个 {{size}} 的文件。先删掉些用不着的文件再重试。", + "upload_landing_progress": "正在写入服务器… {{percent}}%", + "wait_for_op": "等后台操作完成后再改动。", + "wait_for_download": "等下载准备好后再操作。", + "unzip_item": "把 {{name}} 解压到此处", + "unzip_conflicts_title": "解压 {{name}} 会覆盖 {{count}} 个文件", + "unzip_conflicts_body": "压缩包里的这些文件在这里已经存在。确认后会用压缩包里的版本替换它们,其余文件照常解压。旧版本之后可能还要用的话,先做一次备份。", + "unzip_conflicts_more": "还有 {{count}} 个没有列出。", + "unzip_overwrite": "覆盖并解压", + "download_item": "下载 {{name}}", + "download_folder_item": "把文件夹 {{name}} 打包成 .zip 下载", + "download_preparing": "正在准备 {{name}} 的下载…", + "download_started": "已开始下载 {{filename}}。", + "download_started_props": "已开始下载 {{filename}}。里面的 rcon.password 已隐去。", + "download_started_config": "已开始下载 {{filename}}。所有服务器共用的代理转发密钥 paper-global.yml 不在里面。", + "download_failed": "下载没有准备好。", + "download_failed_because": "下载没有准备好:{{reason}}", + "download_busy": "正在准备的文件下载太多了(每人同时两个、每小时最多三十个),请过几分钟再试。", + "secret_config_no_download": "config/paper-global.yml 里有所有服务器共用的代理转发密钥,不能下载。", + "ops_label": "后台操作", + "ops_refresh_failed": "读取后台操作失败:{{reason}}", + "op_unzip": "解压 {{path}}", + "op_upload": "写入 {{path}}", + "op_preparing": "准备中…", + "op_progress": "{{done}} / {{total}} · {{percent}}%", + "op_progress_label": "{{path}} 的进度", + "op_unzip_done": "已解压 {{count}} 个文件,共 {{bytes}}。", + "op_upload_done": "已写入。", + "op_dismiss": "关闭 {{path}} 的结果", + "op_conflicts": "查看冲突", + "op_upload_exists": "这里已经有同名文件,没有替换。重新上传时选「替换」即可。", + "op_unzip_conflicts": "会覆盖 {{count}} 个已有文件,还没有解压。", + "op_volume_full": "世界卷空间不够:需要 {{need}},只剩 {{avail}}。什么都没有改动。", + "archive_invalid": "这个压缩包损坏了,或者不是 zip 文件。什么都没有改动。", + "archive_invalid_entry": "压缩包里的 {{entry}} 损坏了(大小或校验和对不上)。什么都没有改动。", + "archive_unsafe": "压缩包里的 {{entry}} 会写到这个文件夹外面,或者是设备文件。整个压缩包都没有解压。", + "archive_symlink": "压缩包里的 {{entry}} 是符号链接。整个压缩包都没有解压。", + "type_conflict": "压缩包里的 {{entry}} 和这里已有的同名项一个是文件、一个是文件夹,覆盖解决不了。先把这里的 {{entry}} 改名或删掉再解压。", + "job_failed": "后台任务没说明原因就结束了。刷新列表确认一下,再试一次。", + "job_timed_out": "后台任务 2 小时还没做完,被停下了。刷新列表确认一下,再试一次。" } diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index 75946c3..7d687e1 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -1440,6 +1440,107 @@ describe("server file manager wire shapes", () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(FakeXHR.last).toBeUndefined(); }); + + const session = { id: "s1", path: "worlds/big world.zip", size: 100_000_000, received: 0, part_max_bytes: 33_554_432 }; + const op = { + id: "op1", + op: "upload", + path: "worlds/big world.zip", + state: "running", + started_at: "2026-09-28T00:00:00Z", + done: 0, + total: 0, + }; + + it("beginServerFileUpload POSTs the size, with the path in the query", async () => { + const fetchSpy = fakeFetch(session); + vi.stubGlobal("fetch", fetchSpy); + expect(await api.beginServerFileUpload("survival", "worlds/big world.zip", 100_000_000)).toEqual(session); + const [url, opts] = sent(fetchSpy); + expect(url).toBe("/servers/survival/files/uploads?path=worlds%2Fbig%20world.zip"); + expect(opts.method).toBe("POST"); + expect(opts.body).toBe(JSON.stringify({ size: 100_000_000 })); + }); + + it("getServerFileUpload and deleteServerFileUpload name the session in the path", async () => { + const fetchSpy = fakeFetch(session); + vi.stubGlobal("fetch", fetchSpy); + expect(await api.getServerFileUpload("survival", "s1")).toEqual(session); + await api.deleteServerFileUpload("survival", "s1"); + const calls = (fetchSpy as unknown as ReturnType).mock.calls.map(([u, o]) => [ + String(u), + (o as RequestInit).method, + ]); + expect(calls).toEqual([ + ["/servers/survival/files/uploads/s1", "GET"], + ["/servers/survival/files/uploads/s1", "DELETE"], + ]); + }); + + it("putServerFileUploadPart PUTs the part's raw bytes at its offset and reports progress", async () => { + const part = new Blob(["part bytes"]); + const seen: number[] = []; + const done = api.putServerFileUploadPart("survival", "s1", 33_554_432, part, { onProgress: (n) => seen.push(n) }); + const xhr = await sentXHR(); + expect(xhr.method).toBe("PUT"); + expect(xhr.url).toBe("/servers/survival/files/uploads/s1?offset=33554432"); + expect(xhr.withCredentials).toBe(true); + expect(xhr.body).toBe(part); + xhr.upload.onprogress?.({ loaded: 3 }); + xhr.respond(200, JSON.stringify({ ...session, received: 33_554_442 })); + expect(await done).toEqual({ ...session, received: 33_554_442 }); + expect(seen).toEqual([3]); + }); + + it("commitServerFileUpload POSTs overwrite and answers the op landing the file", async () => { + const fetchSpy = fakeFetch({ op }); + vi.stubGlobal("fetch", fetchSpy); + expect(await api.commitServerFileUpload("survival", "s1", true)).toEqual({ op }); + const [url, opts] = sent(fetchSpy); + expect(url).toBe("/servers/survival/files/uploads/s1/commit"); + expect(opts.method).toBe("POST"); + expect(opts.body).toBe(JSON.stringify({ overwrite: true })); + }); + + it("unzipServerFile POSTs overwrite with the archive in the query", async () => { + const unzip = { ...op, op: "unzip", path: "maps/Spawn 2.zip" }; + const fetchSpy = fakeFetch({ op: unzip }); + vi.stubGlobal("fetch", fetchSpy); + expect(await api.unzipServerFile("survival", "maps/Spawn 2.zip", false)).toEqual({ op: unzip }); + const [url, opts] = sent(fetchSpy); + expect(url).toBe("/servers/survival/files/unzip?path=maps%2FSpawn%202.zip"); + expect(opts.method).toBe("POST"); + expect(opts.body).toBe(JSON.stringify({ overwrite: false })); + }); + + it("listServerFileOps GETs the server's ops", async () => { + const fetchSpy = fakeFetch({ ops: [op] }); + vi.stubGlobal("fetch", fetchSpy); + expect(await api.listServerFileOps("survival")).toEqual({ ops: [op] }); + const [url, opts] = sent(fetchSpy); + expect(url).toBe("/servers/survival/files/ops"); + expect(opts.method).toBe("GET"); + }); + + it.each([ + [false, "server.properties", "/servers/survival/files/download?path=server.properties&dir=false"], + [true, "world/data", "/servers/survival/files/download?path=world%2Fdata&dir=true"], + ])("downloadServerFile POSTs the path and whether it is a folder (dir=%s)", async (dir, path, want) => { + const ticket = { ticket: "t1", state: "pending", filename: "x" }; + const fetchSpy = fakeFetch(ticket); + vi.stubGlobal("fetch", fetchSpy); + expect(await api.downloadServerFile("survival", path, dir)).toEqual(ticket); + const [url, opts] = sent(fetchSpy); + expect(url).toBe(want); + expect(opts.method).toBe("POST"); + expect(opts.body).toBeUndefined(); + }); + + it("words the upload session codes in the panel's own copy", () => { + expect(humanizeError({ status: 404, code: "upload_not_found", message: "raw" })).toMatch(/^This upload is gone/); + expect(humanizeError({ status: 429, code: "too_many_uploads", message: "raw" })).toMatch(/4 large uploads in progress/); + expect(humanizeError({ status: 0, code: "op_lost", message: "" })).toMatch(/progress can no longer be read/); + }); }); describe("scheduled task wire shapes", () => { diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 3440cb0..9a7bc55 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -12,6 +12,8 @@ import type { CreateUserRequest, ExportStatus, ExportTicket, + FileOp, + FileUploadSession, FleetServer, Identity, KickResult, @@ -748,9 +750,11 @@ export const api = rejectingSync({ // volume is RWO), so callers gate on phase === "Stopped". The path travels as a // query parameter — a file path contains "/" and never round-trips through a // path segment. Content is []byte on the wire, which Go's encoding/json renders - // as base64, so it is binary-safe in both directions. + // as base64, so it is binary-safe in both directions. A listing also says how + // much room the world volume has (free_bytes), so an upload too big for it is + // refused before it is sent. listServerFiles: (name: string, path: string) => - request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>( + request<{ path: string; entries: ServerFileEntry[]; truncated: boolean; free_bytes: number }>( "GET", urlPath`/servers/${name}/files` + `?path=${encodeURIComponent(path)}`, ), @@ -829,6 +833,66 @@ export const api = rejectingSync({ opts, ), + // A file too big for one request goes up in parts (components/files/ + // sessionUpload.ts drives it): begin a session for its path and size, which + // reserves room for all of it; put each part at its byte offset; then commit, + // which answers at once with the op landing it (watch listServerFileOps). A + // session answers only the account and server it was begun for, stays until + // its file has been fetched whole once, and is dropped after 6 hours idle. + beginServerFileUpload: (name: string, path: string, size: number) => + request( + "POST", + urlPath`/servers/${name}/files/uploads` + `?path=${encodeURIComponent(path)}`, + { size }, + ), + + getServerFileUpload: (name: string, id: string) => + request("GET", urlPath`/servers/${name}/files/uploads/${id}`), + + putServerFileUploadPart: ( + name: string, + id: string, + offset: number, + part: Blob, + opts?: { onProgress?: (sent: number) => void; signal?: AbortSignal }, + ) => + sendWithProgress( + "PUT", + urlPath`/servers/${name}/files/uploads/${id}` + `?offset=${offset}`, + part, + opts, + ), + + deleteServerFileUpload: (name: string, id: string) => + request("DELETE", urlPath`/servers/${name}/files/uploads/${id}`), + + commitServerFileUpload: (name: string, id: string, overwrite: boolean) => + request<{ op: FileOp }>("POST", urlPath`/servers/${name}/files/uploads/${id}/commit`, { overwrite }), + + // unzipServerFile extracts a .zip into the folder holding it, in the + // background. Without overwrite an archive that would replace files fails + // file_exists and lists them, to be confirmed and run again with overwrite. + unzipServerFile: (name: string, path: string, overwrite: boolean) => + request<{ op: FileOp }>( + "POST", + urlPath`/servers/${name}/files/unzip` + `?path=${encodeURIComponent(path)}`, + { overwrite }, + ), + + // listServerFileOps is the server's running op, if any, and those that ended + // within the last 30 minutes, newest first. + listServerFileOps: (name: string) => + request<{ ops: FileOp[] }>("GET", urlPath`/servers/${name}/files/ops`), + + // downloadServerFile starts an export of one file, or of a folder as a zip, + // and answers its ticket (exportStatus, then exportDownloadURL). The world is + // held until the download ends. + downloadServerFile: (name: string, path: string, dir: boolean) => + request( + "POST", + urlPath`/servers/${name}/files/download` + `?path=${encodeURIComponent(path)}&dir=${dir}`, + ), + // Account linking (spec §10). Both are POST: start reports status from the // session principal (no body, side-effect-free), verify consumes a code the // player was shown in-game. The panel can never mint a code — that is the @@ -1307,6 +1371,15 @@ export function humanizeError(e: unknown): string { return t("upload_incomplete"); case "length_required": return t("length_required"); + // An upload sent in parts: the session is gone (cancelled, landed, idle for + // 6 hours, or felis-api restarted), or the account holds four already. + case "upload_not_found": + return t("upload_not_found"); + case "too_many_uploads": + return t("too_many_uploads"); + // Client-side: the op being watched dropped out of the ops list. + case "op_lost": + return t("op_lost"); case "jobs_unavailable": return t("jobs_unavailable"); // Builds, uploads and review: terminal-state conflicts and unwired subsystems. diff --git a/panel/src/lib/contextUpload.ts b/panel/src/lib/contextUpload.ts index 1d293ad..327fdd2 100644 --- a/panel/src/lib/contextUpload.ts +++ b/panel/src/lib/contextUpload.ts @@ -48,7 +48,8 @@ export function retryDelay(n: number): number { return Math.min(1000 * 2 ** (n - 1), 15000); } -function wait(ms: number, signal?: AbortSignal): Promise { +/** wait pauses ms, or rejects with an AbortError as soon as signal aborts. */ +export function wait(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new DOMException("The upload was cancelled", "AbortError")); diff --git a/panel/src/lib/download.ts b/panel/src/lib/download.ts new file mode 100644 index 0000000..d242566 --- /dev/null +++ b/panel/src/lib/download.ts @@ -0,0 +1,41 @@ +import { api } from "./api"; +import type { ExportStatus } from "./types"; + +/** How often a ticket is read while its export Job gets the archive ready. */ +export const EXPORT_POLL_MS = 2000; + +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** awaitExport reads a download's ticket until felis-api holds the archive + * ("ready") or its Job gave up ("failed"), and returns that answer; null once + * `alive` says the page asking has gone (the export is then abandoned: nobody + * fetches it). A ready ticket waits 90 s for the browser, so the caller opens + * it at once. A pending ticket ends on its own (410 export_expired after 10 + * min), which bounds the wait. */ +export async function awaitExport( + ticket: string, + alive: () => boolean, + sleep: (ms: number) => Promise = pause, +): Promise { + for (;;) { + await sleep(EXPORT_POLL_MS); + if (!alive()) return null; + const s = await api.exportStatus(ticket); + if (s.state !== "pending") return s; + } +} + +/** saveDownload hands a ready export to the browser the way a link would: a + * hidden it clicks once. felis-api answers with an attachment, so + * the browser's own download manager streams the archive to disk; reading it + * into a Blob first would hold a whole world in the tab's memory. */ +export function saveDownload(url: string, filename: string) { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.rel = "noopener"; + a.hidden = true; + document.body.appendChild(a); + a.click(); + a.remove(); +} diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index ac835df..7cfae63 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -148,7 +148,7 @@ export interface paths { }; /** * Stream one staged file upload to the Job landing it (one-time bearer token). - * @description PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk and creates a Job to land it in the world volume; the Job fetches the bytes here. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the upload is the whole check. The token opens its upload once. An unknown id, a wrong or missing token and a spent token are all the same 404, so the route says nothing about which uploads exist. + * @description PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk and creates a Job to land it in the world volume; the Job fetches the bytes here. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the upload is the whole check. The token opens its upload once. An unknown id, a wrong or missing token and a spent token are all the same 404, so the route says nothing about which uploads exist. An upload session committed through POST …/files/uploads/{id}/commit is fetched here the same way, under the session id; it stays staged until it has been sent whole once, so a Job that failed before then can be committed again. */ get: operations["internalFileUpload"]; put?: never; @@ -1285,7 +1285,7 @@ export interface paths { put?: never; /** * Start downloading one backup (owner-or-admin plus a former-owner match). - * @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The archive is checked against the sha256 recorded when it was written as it streams; a mismatch aborts the download. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour. + * @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The Job checks the archive against the sha256 recorded when it was written as it streams; a mismatch cuts the download off short of its end. On the way out config/paper-global.yml (the cluster's forwarding secret) is left out and server.properties has its rcon.password redacted, so the download carries no Content-Length. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour. */ post: operations["exportBackup"]; delete?: never; @@ -1305,7 +1305,7 @@ export interface paths { put?: never; /** * Start downloading a stopped server's world as it is now (owner-or-admin). - * @description Starts a Job that archives the server's data volume, read-only, and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The server must be fully stopped, and it cannot start until the download has ended or the Job's 2 hour deadline passes. Same limits as a backup export. + * @description Starts a Job that archives the server's data volume, read-only, and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The server must be fully stopped, and it cannot start until the download has ended or the Job's 2 hour deadline passes. The same two files are guarded as in a backup export, matched by the file itself, so a link to either under another name is guarded too. Same limits as a backup export. */ post: operations["exportWorld"]; delete?: never; @@ -1462,6 +1462,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/servers/{name}/files/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Start downloading one file or folder of a stopped server's world (owner-or-admin). + * @description An export (poll GET /exports/{ticket}, then open its download): a Job reads the file, or zips the folder, from the world volume read-only and hands it to felis-api, which streams it to the browser. A file saves under its own name with its length; a folder as NAME.zip, streamed without one, with symbolic links, devices and sockets left out. config/paper-global.yml, the cluster's forwarding secret, is refused as a file and left out of a folder, and server.properties goes out with its rcon.password redacted; both are matched by the file itself, so a link to either under another name is guarded too. The server cannot start until the download has ended. Two file downloads per user at a time, 4 across the install, 30 per user per hour, counted apart from world and backup exports. Audited as file.download. + */ + post: operations["downloadServerFile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/servers/{name}/files/upload": { parameters: { query?: never; @@ -1472,7 +1492,7 @@ export interface paths { get?: never; /** * Upload a file into a server's world volume (owner-or-admin; server must be stopped). - * @description Lands the raw request body as the file at path, up to 64 MiB — a plugin jar, a datapack, a world region. Content-Length is required (411 length_required). An existing file is 409 file_exists unless overwrite=true; a folder at the path is 400 bad_path either way. The body is staged on felis-api's disk first and then fetched by the file Job with a one-time token, so the world lock is taken only after the body has arrived and a slow upload holds off no backup. The file lands atomically: a synced temporary sibling is checked against the staged size and SHA-256, then renamed into place, so a failed upload leaves the old file whole. Same stopped-gate and os.Root containment as a write. Audited as file.upload. + * @description Lands the raw request body as the file at path, up to 64 MiB — a plugin jar, a datapack, a world region; a bigger file goes up as an upload session (POST …/files/uploads). Content-Length is required (411 length_required). An existing file is 409 file_exists unless overwrite=true; a folder at the path is 400 bad_path either way. The body is staged on felis-api's disk first and then fetched by the file Job with a one-time token, so the world lock is taken only after the body has arrived and a slow upload holds off no backup. The file lands atomically: a synced temporary sibling is checked against the staged size and SHA-256, then renamed into place, so a failed upload leaves the old file whole. Same stopped-gate and os.Root containment as a write. Audited as file.upload. */ put: operations["uploadServerFile"]; post?: never; @@ -1482,6 +1502,111 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/servers/{name}/files/uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Begin an upload session for a file too big for one request (owner-or-admin; server must be stopped). + * @description A file of any size goes up in parts: this begins a session for path and the file's size, PUT …/uploads/{id}?offset= sends each part (at most part_max_bytes, 32 MiB, so each fits the edge's body limit), and POST …/uploads/{id}/commit lands it. There is no size ceiling but felis-api's staging disk, and room for the whole file is reserved here, so an upload that begins is one the disk can finish (507 upload_staging_full otherwise). A session belongs to the account and server it was begun for, answers no one else, and is dropped after 6 hours untouched. Four sessions per account at a time. Sessions do not survive a felis-api restart. + */ + post: operations["beginServerFileUpload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/servers/{name}/files/uploads/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Where an upload session stands (owner-or-admin, the account that began it). + * @description received is where the next part starts: after a lost answer or a 409 upload_offset_mismatch, read it here and continue from there. Needs no stopped server. + */ + get: operations["getServerFileUpload"]; + /** + * Send one part of an upload session (owner-or-admin, the account that began it). + * @description The raw body is appended at offset, which must be where the session ends. Content-Length is required, and the part is taken whole or not at all: one cut short leaves the session where it was. Parts go one at a time (409 upload_busy while one arrives). Needs no stopped server, so starting the server midway costs only the commit's refusal until it is stopped again. + */ + put: operations["putServerFileUploadPart"]; + post?: never; + /** Cancel an upload session and free its room (owner-or-admin, the account that began it). */ + delete: operations["deleteServerFileUpload"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/servers/{name}/files/uploads/{id}/commit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Land a finished upload session in the world volume (owner-or-admin; server must be stopped). + * @description Starts the Job that fetches the session's bytes from felis-api and lands them at its path, checked against their size and SHA-256 and renamed into place, so a failed landing leaves the old file whole. It answers at once with the op; GET …/files/ops reports how it ends (file_exists when a file is at the path and overwrite is not true). The Job holds the world volume while it runs, so the server cannot start meanwhile. A Job that fails before it has every byte leaves the session to commit again; once the bytes have gone to the Job the session is gone. Audited as file.upload. + */ + post: operations["commitServerFileUpload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/servers/{name}/files/unzip": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Extract a .zip into the folder holding it (owner-or-admin; server must be stopped). + * @description Starts a Job that extracts the archive into a temporary folder beside it and moves the result into place, and answers at once with the op; GET …/files/ops reports how it ends. Nothing changes unless every entry is safe: an entry leaving the folder, an absolute path, or a link ends archive_unsafe or archive_symlink; an entry whose size differs from what the archive declares ends archive_invalid; a file where the archive has a folder, or the reverse, ends type_conflict. Without overwrite an archive that would replace any file ends file_exists with the files it would replace, for the caller to confirm and run again with overwrite. Names stored in GBK, as Windows zips in a Chinese locale have them, are read as such. The Job holds the world volume while it runs. Audited as file.unzip. + */ + post: operations["unzipServerFile"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/servers/{name}/files/ops": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * A server's background uploads and extractions (owner-or-admin). + * @description Newest first: the one running, if any, and those that ended within the last 30 minutes, at most 10. Needs no stopped server. + */ + get: operations["listServerFileOps"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/servers/{name}/schedules": { parameters: { query?: never; @@ -2908,6 +3033,88 @@ export interface components { /** @description What the download saves as. */ filename: string; }; + /** @description Where an upload session stands (internal/api/handlers_fileops.go fileSessionView). */ + FileUploadSession: { + /** @description 32 hex characters. */ + id: string; + /** @description Where the file lands */ + path: string; + /** + * Format: int64 + * @description The file's length. + */ + size: number; + /** + * Format: int64 + * @description Bytes here so far; the next part starts here. + */ + received: number; + /** + * Format: int64 + * @description The most one part may carry. + */ + part_max_bytes: number; + }; + StartFileOp: { + /** @description Replace files already there. */ + overwrite?: boolean; + }; + /** @description One background upload or extraction (internal/api/handlers_fileops.go fileOpView). */ + FileOp: { + id: string; + /** @enum {string} */ + op: "upload" | "unzip"; + /** @description The file landed */ + path: string; + /** @enum {string} */ + state: "running" | "succeeded" | "failed"; + /** Format: date-time */ + started_at: string; + /** + * Format: date-time + * @description Omitted while it runs. + */ + finished_at?: string; + /** + * Format: int64 + * @description Bytes landed or extracted so far; 0 before the first report. + */ + done: number; + /** + * Format: int64 + * @description Bytes in all; 0 before the first report. + */ + total: number; + /** @description Files an extraction wrote. Omitted otherwise. */ + files?: number; + /** + * Format: int64 + * @description Bytes an extraction wrote. Omitted otherwise. + */ + bytes?: number; + error?: components["schemas"]["FileOpError"]; + }; + /** @description Why an op failed (internal/api/handlers_fileops.go fileOpError). code is what the synchronous file routes answer for the same refusal (file_exists, volume_full, file_changed, not_found, bad_path), an extraction's own (archive_invalid, archive_unsafe, archive_symlink, type_conflict), or job_failed for a Job that ended without saying why. */ + FileOpError: { + code: string; + message: string; + /** @description The archive entry refused */ + entry?: string; + /** @description On file_exists from an extraction, the first 200 files it would replace, sorted. */ + conflicts?: string[]; + /** @description How many files it would replace in all. */ + conflict_count?: number; + /** + * Format: int64 + * @description On volume_full + */ + need?: number; + /** + * Format: int64 + * @description On volume_full + */ + avail?: number; + }; /** @description Where an export stands (internal/api/exports.go exportStatusView). */ ExportStatus: { /** @@ -6460,7 +6667,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description The tar.gz, as an attachment. */ + /** @description The export as an attachment: a world or a backup as a tar.gz, a downloaded folder as a zip, a downloaded file as its bytes. */ 200: { headers: { "Content-Disposition"?: string; @@ -6468,6 +6675,8 @@ export interface operations { }; content: { "application/gzip": string; + "application/zip": string; + "application/octet-stream": string; }; }; 401: components["responses"]["Unauthorized"]; @@ -6523,7 +6732,7 @@ export interface operations { jobs: { name: string; /** @enum {string} */ - kind: "backup" | "restore" | "export_world" | "export_backup"; + kind: "backup" | "restore" | "export_world" | "export_backup" | "export_files"; /** @enum {string} */ state: "running" | "succeeded" | "failed"; message?: string; @@ -6587,6 +6796,11 @@ export interface operations { path: string; /** @description The listing hit the entry cap and is incomplete. */ truncated: boolean; + /** + * Format: int64 + * @description Bytes free on the world volume + */ + free_bytes: number; entries: { name: string; /** Format: int64 */ @@ -7036,6 +7250,73 @@ export interface operations { }; }; }; + downloadServerFile: { + parameters: { + query: { + /** @description File or folder to download, relative to the world root. The root itself is refused. */ + path: string; + /** @description true when path is a folder, which is sent as a zip. The Job refuses a path that is not what dir says. */ + dir?: "true" | "false"; + }; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Download started. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExportTicket"]; + }; + }; + /** @description Missing path (bad_request), the world root (bad_path), or a malformed server name (bad_name). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description Unknown server. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server is not stopped (not_stopped), has no world volume yet (no_world_volume), or a restore, backup, file change or another export already holds its world volume (maintenance_in_progress). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description A file download limit is reached (export_busy); Retry-After gives the seconds to wait. */ + 429: { + headers: { + "Retry-After"?: number; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; uploadServerFile: { parameters: { query: { @@ -7114,7 +7395,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; - /** @description The file is over 64 MiB (too_large). */ + /** @description The file is over 64 MiB, the most one request carries (too_large); send it as an upload session instead. */ 413: { headers: { [name: string]: unknown; @@ -7144,6 +7425,436 @@ export interface operations { }; }; }; + beginServerFileUpload: { + parameters: { + query: { + /** @description File to create, relative to the world root. It must stay inside it (400 bad_path); its folder is checked when the file lands. */ + path: string; + }; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * Format: int64 + * @description The file's length in bytes. + */ + size: number; + }; + }; + }; + responses: { + /** @description Session begun. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FileUploadSession"]; + }; + }; + /** @description Missing path or size, or a negative size (bad_request), a path leaving the world folder or naming the folder itself (bad_path), or a malformed server name (bad_name). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description Unknown server. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server is not stopped (not_stopped) or has no world volume yet (no_world_volume). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The account already has 4 uploads in progress (too_many_uploads). */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + /** @description felis-api's staging disk has no room for a file this size right now (upload_staging_full). */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getServerFileUpload: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The session. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FileUploadSession"]; + }; + }; + /** @description Malformed server name (bad_name). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description Unknown server, or no such session for this account on this server (upload_not_found). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + putServerFileUploadPart: { + parameters: { + query: { + /** @description The byte position the part starts at, the session's received. */ + offset: number; + }; + header?: never; + path: { + name: string; + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/octet-stream": string; + }; + }; + responses: { + /** @description Part taken. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FileUploadSession"]; + }; + }; + /** @description A missing or malformed offset (bad_request), a body that ended before its Content-Length (upload_incomplete), or a malformed server name (bad_name). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description Unknown server, or no such session for this account on this server (upload_not_found). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description offset is not where the session ends (upload_offset_mismatch), or another part is still arriving (upload_busy). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The request has no Content-Length (length_required). */ + 411: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The part is over part_max_bytes, or runs past the size the session began with (part_too_large). */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + /** @description felis-api's staging disk ran out of room (upload_staging_full). */ + 507: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + deleteServerFileUpload: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cancelled. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Malformed server name (bad_name). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description Unknown server, or no such session for this account on this server (upload_not_found). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description A part is still arriving (upload_busy). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + commitServerFileUpload: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StartFileOp"]; + }; + }; + responses: { + /** @description Landing started. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + op: components["schemas"]["FileOp"]; + }; + }; + }; + /** @description Malformed body, or a malformed server name (bad_name). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description Unknown server, or no such session for this account on this server (upload_not_found). */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not every byte has arrived (upload_incomplete; the world lock is not asked for), a part is still arriving (upload_busy), the server is not stopped (not_stopped) or has no world volume yet (no_world_volume), or a restore, backup, file change or export already holds its world volume, this session's earlier commit included (maintenance_in_progress). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + unzipServerFile: { + parameters: { + query: { + /** @description The .zip to extract, relative to the world root. */ + path: string; + }; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StartFileOp"]; + }; + }; + responses: { + /** @description Extraction started. */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + op: components["schemas"]["FileOp"]; + }; + }; + }; + /** @description Missing path or malformed body (bad_request), a path not ending in .zip (bad_path), or a malformed server name (bad_name). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description Unknown server. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server is not stopped (not_stopped), has no world volume yet (no_world_volume), or a restore, backup, file change or export already holds its world volume (maintenance_in_progress). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + listServerFileOps: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The ops. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ops: components["schemas"]["FileOp"][]; + }; + }; + }; + /** @description Malformed server name (bad_name). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + /** @description Unknown server. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; listServerSchedules: { parameters: { query?: never; diff --git a/panel/src/lib/types.parity.ts b/panel/src/lib/types.parity.ts index 3635081..e89f498 100644 --- a/panel/src/lib/types.parity.ts +++ b/panel/src/lib/types.parity.ts @@ -41,6 +41,9 @@ export type WireParity = [ Holds>, Holds>, Holds>, + Holds>, + Holds>, + Holds>, Holds>, Holds>, Holds>, diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 3e7dfee..8bf0f83 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -229,11 +229,11 @@ export interface BackupView { /** ServerJob is one row of GET /api/v1/servers/{name}/jobs — the observable * outcome of an async backup/restore/export Job. The API only enqueues Jobs, so * this projection is how a 202 that later failed becomes visible in the panel. - * An export_world Job holds the world until its download ends; an - * export_backup Job only reads the backup store. */ + * An export_world or export_files Job holds the world until its download + * ends; an export_backup Job only reads the backup store. */ export interface ServerJob { name: string; - kind: string; // "backup" | "restore" | "export_world" | "export_backup" + kind: string; // "backup" | "restore" | "export_world" | "export_backup" | "export_files" state: string; // "running" | "succeeded" | "failed" message?: string; started_at?: string; @@ -418,6 +418,48 @@ export interface ServerFileEntry { mod_time: string; } +/** FileUploadSession is where an upload sent in parts stands + * (internal/api/handlers_fileops.go fileSessionView): the next part starts at + * `received` and carries at most `part_max_bytes`. */ +export interface FileUploadSession { + id: string; + path: string; + size: number; + received: number; + part_max_bytes: number; +} + +/** FileOp is one background upload landing or extraction + * (handlers_fileops.go fileOpView). `done` and `total` are bytes, both 0 until + * the Job first reports; `files` and `bytes` are what an extraction wrote. */ +export interface FileOp { + id: string; + op: "upload" | "unzip"; + path: string; + state: "running" | "succeeded" | "failed"; + started_at: string; + finished_at?: string; + done: number; + total: number; + files?: number; + bytes?: number; + error?: FileOpError; +} + +/** FileOpError is why an op failed (handlers_fileops.go fileOpError). On + * file_exists from an extraction, `conflicts` lists the first 200 files it + * would replace and `conflict_count` all of them; on volume_full, `need` and + * `avail` are bytes. */ +export interface FileOpError { + code: string; + message: string; + entry?: string; + conflicts?: string[]; + conflict_count?: number; + need?: number; + avail?: number; +} + /** Build mirrors an image_builds row (spec §6, §16). */ export interface Build { id: string; diff --git a/panel/src/pages/ServerBackups.test.tsx b/panel/src/pages/ServerBackups.test.tsx index 165a7d5..e26b7ca 100644 --- a/panel/src/pages/ServerBackups.test.tsx +++ b/panel/src/pages/ServerBackups.test.tsx @@ -108,6 +108,27 @@ describe("ServerBackups", () => { expect(ops.map((el) => el.textContent)).toEqual(["Scheduled backup", "Backup"]); }); + it("names each kind of recent operation, with a download icon on the downloads", async () => { + calls.serverJobs.mockResolvedValue([ + { name: "restore-survival-aa", kind: "restore", state: "succeeded" }, + { name: "backup-survival-bb", kind: "backup", state: "succeeded", then_restore: "done" }, + { name: "export-survival-cc", kind: "export_world", state: "succeeded" }, + { name: "export-survival-dd", kind: "export_backup", state: "failed" }, + { name: "files-survival-ee", kind: "export_files", state: "succeeded" }, + ]); + renderPage(); + await screen.findByText("File download", { selector: "li span" }); + + const rows = screen.getAllByRole("listitem").filter((li) => li.querySelector("span.text-sm")); + expect(rows.map((li) => [li.querySelector("span.text-sm")?.textContent, li.querySelector("svg")?.classList.contains("lucide-download")])).toEqual([ + ["Restore", false], + ["Pre-restore snapshot", false], + ["World export", true], + ["Backup download", true], + ["File download", true], + ]); + }); + it("names the archive the reaper leaves when a server is given up or deleted", async () => { calls.listBackups.mockResolvedValue({ backups: [{ ...backup("bk-rel", 4), reason: "released" }], total: 1 }); renderPage(); diff --git a/panel/src/pages/ServerBackups.tsx b/panel/src/pages/ServerBackups.tsx index d1f030d..80b8f13 100644 --- a/panel/src/pages/ServerBackups.tsx +++ b/panel/src/pages/ServerBackups.tsx @@ -35,6 +35,7 @@ import { Loading, ErrorState, EmptyState, NotYours, RefreshError } from "@/compo import { PageHeader } from "@/components/PageHeader"; import { Pagination } from "@/components/Pagination"; import { api, humanizeError } from "@/lib/api"; +import { awaitExport, saveDownload } from "@/lib/download"; import { useAsync, usePolling, STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import { canManage, ownershipPending } from "@/lib/ownership"; @@ -231,21 +232,6 @@ function DownloadBackupButton({ ); } -/** saveDownload hands a ready export to the browser the way a link would: a - * hidden it clicks once. felis-api answers with an attachment, so - * the browser's own download manager streams the archive to disk; reading it - * into a Blob first would hold a whole world in the tab's memory. */ -function saveDownload(url: string, filename: string) { - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.rel = "noopener"; - a.hidden = true; - document.body.appendChild(a); - a.click(); - a.remove(); -} - /** DeleteBackupButton deletes one backup behind a confirm that says what goes: the * row leaves the list at once and can no longer be restored, and the archive and * its off-site copy follow at the next cleanup and sync. A reclaimed or released @@ -470,9 +456,6 @@ const POLL_MS = 2500; // them in game and holds the stop 30 s, then the pre-stop save and the pod's own // shutdown save follow. const MAX_POLLS = 48; -// How often an export being prepared rereads its ticket: the Job needs a few -// seconds to start, and a ready ticket waits 90 s for the browser. -const EXPORT_POLL_MS = 2000; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); function RestoreControls({ @@ -746,11 +729,9 @@ export function ServerBackups() { }, []); // A download is prepared before it starts: the POST answers a one-time ticket, - // an export Job hands the archive to felis-api, and this reads the ticket - // until felis-api holds it. Only then does the browser fetch it, as a link, so - // any size of world streams straight to disk. A ready ticket waits 90 s for - // that fetch, so it follows at once. A pending ticket ends on its own (410 - // export_expired after 10 min), which bounds the wait. + // an export Job hands the archive to felis-api, and awaitExport reads the + // ticket until felis-api holds it. Only then does the browser fetch it, as a + // link, so any size of world streams straight to disk. async function runExport(target: ExportTarget) { if (exportRun.current) return; exportRun.current = true; @@ -760,18 +741,14 @@ export function ServerBackups() { const tk = target.kind === "world" ? await api.startWorldExport(name) : await api.startBackupExport(name, target.id); jobsQ.reload(); // a world export now holds the world - for (;;) { - await sleep(EXPORT_POLL_MS); - if (!alive.current) return; - const s = await api.exportStatus(tk.ticket); - if (s.state === "ready") break; - if (s.state === "failed") { - setBackupMsg({ - kind: "error", - text: s.message ? t("export_failed_because", { reason: s.message }) : t("export_failed"), - }); - return; - } + const s = await awaitExport(tk.ticket, () => alive.current); + if (s === null) return; + if (s.state === "failed") { + setBackupMsg({ + kind: "error", + text: s.message ? t("export_failed_because", { reason: s.message }) : t("export_failed"), + }); + return; } saveDownload(await api.exportDownloadURL(tk.ticket), tk.filename); setBackupMsg({ @@ -1000,7 +977,7 @@ export function ServerBackups() {
    {j.kind === "restore" ? ( - ) : j.kind === "export_world" || j.kind === "export_backup" ? ( + ) : j.kind === "export_world" || j.kind === "export_backup" || j.kind === "export_files" ? ( ) : ( @@ -1018,6 +995,8 @@ export function ServerBackups() { ? t("job_export_world") : j.kind === "export_backup" ? t("job_export_backup") + : j.kind === "export_files" + ? t("job_export_files") : j.kind} {at && ( diff --git a/panel/src/pages/ServerFiles.test.tsx b/panel/src/pages/ServerFiles.test.tsx index 5626b71..51edea8 100644 --- a/panel/src/pages/ServerFiles.test.tsx +++ b/panel/src/pages/ServerFiles.test.tsx @@ -6,8 +6,11 @@ import { MemoryRouter, Route, Routes } from "react-router-dom"; import i18next from "i18next"; import { ServerFiles } from "./ServerFiles"; import { humanizeError } from "@/lib/api"; -import { MAX_UPLOAD_BYTES } from "@/components/files/useUploads"; +import { ONE_REQUEST_BYTES } from "@/components/files/useUploads"; +import { OP_POLL_MS } from "@/components/files/sessionUpload"; +import { EXPORT_POLL_MS } from "@/lib/download"; import { STATUS_POLL_FAST_MS } from "@/lib/hooks"; +import type { FileOp } from "@/lib/types"; const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn(), @@ -20,6 +23,16 @@ const mocks = vi.hoisted(() => ({ mkdirServerFolder: vi.fn(), renameServerFile: vi.fn(), uploadServerFile: vi.fn(), + listServerFileOps: vi.fn(), + unzipServerFile: vi.fn(), + downloadServerFile: vi.fn(), + exportStatus: vi.fn(), + exportDownloadURL: vi.fn(), + beginServerFileUpload: vi.fn(), + getServerFileUpload: vi.fn(), + putServerFileUploadPart: vi.fn(), + deleteServerFileUpload: vi.fn(), + commitServerFileUpload: vi.fn(), })); vi.mock("@/lib/api", async (importOriginal) => { @@ -38,6 +51,16 @@ vi.mock("@/lib/api", async (importOriginal) => { mkdirServerFolder: mocks.mkdirServerFolder, renameServerFile: mocks.renameServerFile, uploadServerFile: mocks.uploadServerFile, + listServerFileOps: mocks.listServerFileOps, + unzipServerFile: mocks.unzipServerFile, + downloadServerFile: mocks.downloadServerFile, + exportStatus: mocks.exportStatus, + exportDownloadURL: mocks.exportDownloadURL, + beginServerFileUpload: mocks.beginServerFileUpload, + getServerFileUpload: mocks.getServerFileUpload, + putServerFileUploadPart: mocks.putServerFileUploadPart, + deleteServerFileUpload: mocks.deleteServerFileUpload, + commitServerFileUpload: mocks.commitServerFileUpload, }, }; }); @@ -62,6 +85,18 @@ async function openEditor() { return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement }; } +let opsNow: FileOp[] = []; +const fileOp = (over: Partial = {}): FileOp => ({ + id: "op1", + op: "unzip", + path: "pack.zip", + state: "running", + started_at: "2026-09-28T00:00:00Z", + done: 0, + total: 0, + ...over, +}); + const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredState: "Stopped", ready: false }; beforeEach(() => { @@ -84,6 +119,24 @@ beforeEach(() => { for (const m of [mocks.createServerFile, mocks.deleteServerFile, mocks.mkdirServerFolder, mocks.renameServerFile, mocks.uploadServerFile]) { m.mockReset(); } + for (const m of [ + mocks.unzipServerFile, + mocks.downloadServerFile, + mocks.exportStatus, + mocks.exportDownloadURL, + mocks.beginServerFileUpload, + mocks.getServerFileUpload, + mocks.putServerFileUploadPart, + mocks.deleteServerFileUpload, + mocks.commitServerFileUpload, + ]) { + m.mockReset(); + } + // The server's background ops, as each read finds them. + opsNow = []; + mocks.listServerFileOps.mockReset(); + mocks.listServerFileOps.mockImplementation(async () => ({ ops: opsNow })); + localStorage.clear(); mocks.stop.mockReset(); mocks.status.mockReset(); mocks.status.mockResolvedValue(stopped); @@ -621,20 +674,298 @@ describe("ServerFiles uploads", () => { expect(mocks.uploadServerFile).not.toHaveBeenCalled(); }); - it("refuses a name a folder holds, and a file over the limit, without sending or offering a retry", async () => { + const sized = (name: string, size: number) => { + const f = file(name); + Object.defineProperty(f, "size", { value: size }); + return f; + }; + const withFree = (free: number) => + mocks.listServerFiles.mockImplementation((_name: string, path: string) => + Promise.resolve({ + path, + truncated: false, + free_bytes: free, + entries: [{ name: "world", size: 0, is_dir: true, mod_time: "2026-09-01T00:00:00Z" }], + }), + ); + + it("refuses a name a folder holds without sending or offering a retry", async () => { renderFiles(); await screen.findByText("world"); - const big = file("world-backup.zip"); - Object.defineProperty(big, "size", { value: MAX_UPLOAD_BYTES + 1 }); - const exact = file("exact.zip"); - Object.defineProperty(exact, "size", { value: MAX_UPLOAD_BYTES }); - pick(file("world"), big, exact); + pick(file("world")); expect(await within(queue()).findByText(t("files:upload_folder_there"))).toBeTruthy(); - expect(within(queue()).getByText(i18next.t("files:upload_too_large", { limit: "64 MiB" }))).toBeTruthy(); expect(within(queue()).queryByRole("button", { name: t("files:upload_retry") })).toBeNull(); - await waitFor(() => expect(sentAs()).toEqual([["exact.zip", false]])); + expect(mocks.uploadServerFile).not.toHaveBeenCalled(); + }); + + it("refuses what the volume has no room for, counting the files ahead in the batch, and retries once there is room", async () => { + withFree(100); + renderFiles(); + await screen.findByText("world"); + + pick(sized("a.jar", 60), sized("b.jar", 50), sized("c.jar", 40)); + + expect(await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "40 B", size: "50 B" }))).toBeTruthy(); + await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]])); + await act(async () => pending[0].resolve()); + await waitFor(() => expect(sentAs()).toEqual([["a.jar", false], ["c.jar", false]])); + await act(async () => pending[1].resolve()); + await waitFor(() => expect(within(queue()).getAllByText(t("files:upload_done"))).toHaveLength(2)); + + // The listing after they landed still says 100 B free. + await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_retry") })); + await waitFor(() => expect(sentAs()).toEqual([["a.jar", false], ["c.jar", false], ["b.jar", false]])); + }); + + it("sends when the listing could not tell how much room there is", async () => { + withFree(0); + renderFiles(); + await screen.findByText("world"); + + pick(sized("a.jar", 60)); + + await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]])); + }); + + it("keeps a retry refused while the latest listing has no room for the file", async () => { + withFree(10); + renderFiles(); + await screen.findByText("world"); + pick(sized("a.jar", 60)); + await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "10 B", size: "60 B" })); + + withFree(20); + await userEvent.click(screen.getByRole("button", { name: t("files:refresh") })); + await waitFor(() => expect(mocks.listServerFiles).toHaveBeenCalledTimes(2)); + await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_retry") })); + + expect(await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "20 B", size: "60 B" }))).toBeTruthy(); + expect(mocks.uploadServerFile).not.toHaveBeenCalled(); + }); + + it("sends a file of exactly one request's worth in one request", async () => { + renderFiles(); + await screen.findByText("world"); + + pick(sized("edge.zip", ONE_REQUEST_BYTES)); + + await waitFor(() => expect(sentAs()).toEqual([["edge.zip", false]])); + expect(mocks.beginServerFileUpload).not.toHaveBeenCalled(); + }); + + describe("a file over one request's worth", () => { + const PART = 32 * 1024 * 1024; + const SIZE = ONE_REQUEST_BYTES + 1; + const KEY = "felis-file-upload:lobby:world.zip"; + const at = (received: number) => ({ id: "s1", path: "world.zip", size: SIZE, received, part_max_bytes: PART }); + let parts: { offset: number; signal?: AbortSignal }[]; + + beforeEach(() => { + parts = []; + mocks.beginServerFileUpload.mockResolvedValue(at(0)); + mocks.putServerFileUploadPart.mockImplementation( + async (_n: string, _id: string, offset: number, _part: Blob, opts?: { signal?: AbortSignal }) => { + parts.push({ offset, signal: opts?.signal }); + return at(Math.min(offset + PART, SIZE)); + }, + ); + mocks.commitServerFileUpload.mockResolvedValue({ op: fileOp({ id: "land1", op: "upload", path: "world.zip" }) }); + mocks.deleteServerFileUpload.mockResolvedValue(null); + }); + + it("goes up in parts, shows the Job writing it, and rereads once it lands", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderFiles(); + await screen.findByText("world"); + const lists = mocks.listServerFiles.mock.calls.length; + + pick(sized("world.zip", SIZE)); + + await waitFor(() => expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s1", false]])); + expect(parts.map((p) => p.offset)).toEqual([0, PART, 2 * PART]); + expect(mocks.uploadServerFile).not.toHaveBeenCalled(); + expect(await within(queue()).findByText(t("files:upload_landing"))).toBeTruthy(); + + opsNow = [fileOp({ id: "land1", op: "upload", path: "world.zip", done: SIZE / 4, total: SIZE })]; + await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + expect(within(queue()).getByText(i18next.t("files:upload_landing_progress", { percent: 25 }))).toBeTruthy(); + expect(within(queue()).getByRole("progressbar").getAttribute("aria-valuenow")).toBe("25"); + // Its own row follows it; the list of background operations leaves it out. + fireEvent.click(screen.getByRole("button", { name: t("files:refresh") })); + await waitFor(() => expect(mocks.listServerFileOps.mock.calls.length).toBeGreaterThanOrEqual(2)); + expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull(); + const before = mocks.listServerFiles.mock.calls.length; + + opsNow = [fileOp({ id: "land1", op: "upload", path: "world.zip", state: "succeeded", done: SIZE, total: SIZE })]; + await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + + expect(await within(queue()).findByText(t("files:upload_done"))).toBeTruthy(); + await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(before + 1)); + expect(before).toBe(lists + 1); // the refresh + expect(localStorage.getItem(KEY)).toBeNull(); + }); + + it("says why the Job landing it failed, and offers a retry", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderFiles(); + await screen.findByText("world"); + pick(sized("world.zip", SIZE)); + await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalled()); + + opsNow = [ + fileOp({ + id: "land1", + op: "upload", + path: "world.zip", + state: "failed", + error: { code: "volume_full", message: "", need: 2048, avail: 1024 }, + }), + ]; + await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + + expect( + await within(queue()).findByText(i18next.t("files:op_volume_full", { need: "2.0 KiB", avail: "1.0 KiB" })), + ).toBeTruthy(); + expect(within(queue()).getByRole("button", { name: t("files:upload_retry") })).toBeTruthy(); + }); + + it("asks to replace when the Job found a file there, and lands the bytes already sent", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderFiles(); + await screen.findByText("world"); + pick(sized("world.zip", SIZE)); + await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1)); + opsNow = [ + fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error: { code: "file_exists", message: "" } }), + ]; + await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + mocks.getServerFileUpload.mockResolvedValue(at(SIZE)); + mocks.commitServerFileUpload.mockResolvedValue({ + op: fileOp({ id: "land2", op: "upload", path: "world.zip", state: "succeeded" }), + }); + + fireEvent.click(await within(queue()).findByRole("button", { name: t("files:upload_replace") })); + + await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenLastCalledWith("lobby", "s1", true)); + expect(parts).toHaveLength(3); + expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(1); + expect(await within(queue()).findByText(t("files:upload_done"))).toBeTruthy(); + }); + + it.each([ + ["dismissed", { code: "volume_full", message: "" }, "upload_dismiss", { name: "world.zip" }], + ["skipped", { code: "file_exists", message: "" }, "upload_skip", {}], + ])("gives the session back when a refused one is %s", async (_label, error, key, opts) => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderFiles(); + await screen.findByText("world"); + pick(sized("world.zip", SIZE)); + await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1)); + opsNow = [fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error })]; + await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + + fireEvent.click(await within(queue()).findByRole("button", { name: i18next.t(`files:${key}`, opts) })); + + await waitFor(() => expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s1"]])); + expect(localStorage.getItem(KEY)).toBeNull(); + expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull(); + }); + + it("gives the session back when skipped with the rest", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderFiles(); + await screen.findByText("world"); + pick(sized("world.zip", SIZE)); + await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1)); + opsNow = [ + fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error: { code: "file_exists", message: "" } }), + ]; + await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + await within(queue()).findByRole("button", { name: t("files:upload_replace") }); + pick(file("server.properties")); + + fireEvent.click(await within(queue()).findByRole("button", { name: t("files:upload_skip_all") })); + + await waitFor(() => expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s1"]])); + expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull(); + }); + + it("gives the session back when cancelled", async () => { + mocks.putServerFileUploadPart.mockImplementation( + (_n: string, _id: string, offset: number, _part: Blob, opts?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + parts.push({ offset, signal: opts?.signal }); + opts?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError"))); + }), + ); + renderFiles(); + await screen.findByText("world"); + pick(sized("world.zip", SIZE)); + await waitFor(() => expect(parts).toHaveLength(1)); + expect(localStorage.getItem(KEY)).not.toBeNull(); + + await userEvent.click(within(queue()).getByRole("button", { name: i18next.t("files:upload_cancel", { name: "world.zip" }) })); + + await waitFor(() => expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s1"]])); + expect(localStorage.getItem(KEY)).toBeNull(); + }); + + it("keeps the session for a resume when the page goes away", async () => { + mocks.putServerFileUploadPart.mockImplementation( + (_n: string, _id: string, offset: number, _part: Blob, opts?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + parts.push({ offset, signal: opts?.signal }); + opts?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError"))); + }), + ); + const { unmount } = renderFiles(); + await screen.findByText("world"); + pick(sized("world.zip", SIZE)); + await waitFor(() => expect(parts).toHaveLength(1)); + + unmount(); + await act(async () => {}); + + expect(parts[0].signal?.aborted).toBe(true); + expect(mocks.deleteServerFileUpload).not.toHaveBeenCalled(); + expect(JSON.parse(localStorage.getItem(KEY) ?? "null")).toMatchObject({ id: "s1", size: SIZE }); + }); + }); + + it("holds the queue and every change while a background op runs, then carries on", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + opsNow = [fileOp()]; + renderFiles(); + const ops = await screen.findByRole("region", { name: t("files:ops_label") }); + expect(within(ops).getByText(i18next.t("files:op_unzip", { path: "pack.zip" }))).toBeTruthy(); + expect(within(ops).getByText(t("files:op_preparing"))).toBeTruthy(); + const lists = mocks.listServerFiles.mock.calls.length; + + pick(file("a.jar")); + expect(await within(queue()).findByText(t("files:upload_queued"))).toBeTruthy(); + for (const b of [button("new_file"), button("new_folder"), button("rename_item", "world"), button("delete_item", "world")]) { + expect(b.disabled).toBe(true); + expect(b.title).toBe(t("files:wait_for_op")); + } + + opsNow = [fileOp({ done: 512, total: 2048 })]; + await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + expect(within(ops).getByText(i18next.t("files:op_progress", { done: "512 B", total: "2.0 KiB", percent: 25 }))).toBeTruthy(); + expect(within(ops).getByRole("progressbar").getAttribute("aria-valuenow")).toBe("25"); + expect(mocks.uploadServerFile).not.toHaveBeenCalled(); + + opsNow = [fileOp({ state: "succeeded", files: 3, bytes: 2048, done: 2048, total: 2048 })]; + await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + + await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]])); + expect(within(ops).getByText(i18next.t("files:op_unzip_done", { count: 3, bytes: "2.0 KiB" }))).toBeTruthy(); + // What it extracted is in the folder now. + await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1)); + + fireEvent.click(within(ops).getByRole("button", { name: i18next.t("files:op_dismiss", { path: "pack.zip" }) })); + expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull(); }); it("asks about a file that appeared since the listing", async () => { @@ -808,3 +1139,308 @@ describe("ServerFiles uploads", () => { expect(fireEvent.dragOver(document.body, { dataTransfer: { types: ["text/plain"], dropEffect: "move" } })).toBe(true); }); }); + +describe("ServerFiles archives and downloads", () => { + const entry = (name: string, is_dir = false) => ({ name, size: 8, is_dir, mod_time: "2026-09-01T00:00:00Z" }); + beforeEach(() => { + mocks.listServerFiles.mockImplementation((_name: string, path: string) => + Promise.resolve({ + path, + truncated: false, + entries: + path === "config" + ? [entry("paper-global.yml"), entry("bukkit.yml")] + : [entry("server.properties"), entry("Pack.ZIP"), entry("old.zip", true), entry("config", true), entry("world", true)], + }), + ); + }); + + const conflicted = (over: Partial = {}) => + fileOp({ + id: "u1", + path: "Pack.ZIP", + state: "failed", + error: { code: "file_exists", message: "", conflicts: ["world/level.dat", "ops.json"], conflict_count: 3 }, + ...over, + }); + const opsBanner = () => screen.getByRole("region", { name: t("files:ops_label") }); + const poll = () => act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS)); + + it("extracts an archive, lists what it would replace, and replaces them once confirmed", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + renderFiles(); + await screen.findByText("Pack.ZIP"); + expect(screen.queryByRole("button", { name: i18next.t("files:unzip_item", { name: "server.properties" }) })).toBeNull(); + expect(screen.queryByRole("button", { name: i18next.t("files:unzip_item", { name: "old.zip" }) })).toBeNull(); + const first = fileOp({ id: "u1", path: "Pack.ZIP" }); + mocks.unzipServerFile.mockResolvedValueOnce({ op: first }); + opsNow = [first]; + + fireEvent.click(button("unzip_item", "Pack.ZIP")); + + const banner = await screen.findByRole("region", { name: t("files:ops_label") }); + expect(within(banner).getByText(i18next.t("files:op_unzip", { path: "Pack.ZIP" }))).toBeTruthy(); + expect(mocks.unzipServerFile.mock.calls).toEqual([["lobby", "Pack.ZIP", false]]); + expect(button("unzip_item", "Pack.ZIP").disabled).toBe(true); + expect(button("unzip_item", "Pack.ZIP").title).toBe(t("files:wait_for_op")); + + opsNow = [conflicted()]; + await poll(); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_title", { name: "Pack.ZIP", count: 3 }))).toBeTruthy(); + expect(within(dialog).getAllByRole("listitem").map((li) => li.textContent)).toEqual(["world/level.dat", "ops.json"]); + expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_more", { count: 1 }))).toBeTruthy(); + const lists = mocks.listServerFiles.mock.calls.length; + + const second = fileOp({ id: "u2", path: "Pack.ZIP" }); + mocks.unzipServerFile.mockResolvedValueOnce({ op: second }); + opsNow = [second, conflicted()]; + fireEvent.click(within(dialog).getByRole("button", { name: t("files:unzip_overwrite") })); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(mocks.unzipServerFile.mock.calls).toEqual([ + ["lobby", "Pack.ZIP", false], + ["lobby", "Pack.ZIP", true], + ]); + // The refused try is dismissed: the one replacing takes its place. + expect(within(opsBanner()).getAllByRole("listitem")).toHaveLength(1); + expect(within(opsBanner()).getByText(t("files:op_preparing"))).toBeTruthy(); + + opsNow = [fileOp({ id: "u2", path: "Pack.ZIP", state: "succeeded", files: 5, bytes: 1024 }), conflicted()]; + await poll(); + + expect(within(opsBanner()).getByText(i18next.t("files:op_unzip_done", { count: 5, bytes: "1.0 KiB" }))).toBeTruthy(); + await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1)); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("holds every change while an extraction starts, and asks at once when it ended before a read", async () => { + let answer!: (v: { op: FileOp }) => void; + mocks.unzipServerFile.mockReturnValueOnce(new Promise((res) => (answer = res))); + renderFiles(); + await screen.findByText("Pack.ZIP"); + + fireEvent.click(button("unzip_item", "Pack.ZIP")); + + for (const b of [button("new_file"), button("download_item", "server.properties"), button("delete_item", "world")]) { + expect(b.disabled).toBe(true); + expect(b.title).toBe(t("files:wait_for_op")); + } + mocks.uploadServerFile.mockResolvedValue({ path: "a.jar", status: "uploaded", sha256: "a", size: 5 }); + fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [new File(["bytes"], "a.jar")] } }); + await act(async () => {}); + expect(mocks.uploadServerFile).not.toHaveBeenCalled(); + + await act(async () => answer({ op: conflicted() })); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_title", { name: "Pack.ZIP", count: 3 }))).toBeTruthy(); + await waitFor(() => expect(mocks.uploadServerFile.mock.calls.map((c) => c[1])).toEqual(["a.jar"])); + }); + + it("asks nothing about an extraction started here that failed for another reason", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const started = fileOp({ id: "u1", path: "Pack.ZIP" }); + mocks.unzipServerFile.mockResolvedValueOnce({ op: started }); + renderFiles(); + await screen.findByText("Pack.ZIP"); + opsNow = [started]; + fireEvent.click(button("unzip_item", "Pack.ZIP")); + await screen.findByRole("region", { name: t("files:ops_label") }); + expect(mocks.unzipServerFile.mock.calls).toEqual([["lobby", "Pack.ZIP", false]]); + + opsNow = [conflicted({ error: { code: "archive_invalid", message: "zip: not a valid zip file" } })]; + await poll(); + + expect(within(opsBanner()).getByText(t("files:archive_invalid"))).toBeTruthy(); + expect(within(opsBanner()).queryByRole("button", { name: t("files:op_conflicts") })).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("asks nothing at once about an extraction started elsewhere, and offers its conflicts", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + opsNow = [fileOp({ id: "u9", path: "maps/Pack.ZIP" })]; + renderFiles(); + await within(await screen.findByRole("region", { name: t("files:ops_label") })).findByText(t("files:op_preparing")); + + opsNow = [conflicted({ id: "u9", path: "maps/Pack.ZIP", error: { code: "file_exists", message: "", conflicts: ["a.txt"] } })]; + await poll(); + + expect(within(opsBanner()).getByText(i18next.t("files:op_unzip_conflicts", { count: 1 }))).toBeTruthy(); + expect(screen.queryByRole("dialog")).toBeNull(); + fireEvent.click(within(opsBanner()).getByRole("button", { name: t("files:op_conflicts") })); + + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_title", { name: "Pack.ZIP", count: 1 }))).toBeTruthy(); + expect(within(dialog).getAllByRole("listitem").map((li) => li.textContent)).toEqual(["a.txt"]); + expect(within(dialog).queryByText(i18next.t("files:unzip_conflicts_more", { count: 0 }))).toBeNull(); + mocks.unzipServerFile.mockResolvedValueOnce({ op: fileOp({ id: "u10", path: "maps/Pack.ZIP" }) }); + + fireEvent.click(within(dialog).getByRole("button", { name: t("files:unzip_overwrite") })); + + await waitFor(() => expect(mocks.unzipServerFile.mock.calls).toEqual([["lobby", "maps/Pack.ZIP", true]])); + }); + + it("says why an extraction could not start, and lets it be tried again", async () => { + const held = { status: 409, code: "maintenance_in_progress", message: "a backup holds the world" }; + mocks.unzipServerFile.mockRejectedValueOnce(held); + renderFiles(); + await screen.findByText("Pack.ZIP"); + + fireEvent.click(button("unzip_item", "Pack.ZIP")); + + expect(await screen.findByText(humanizeError(held))).toBeTruthy(); + expect(button("unzip_item", "Pack.ZIP").disabled).toBe(false); + expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull(); + }); + + it("says when the background ops cannot be read, until a refresh gets through", async () => { + const broke = { status: 500, code: "internal", message: "" }; + mocks.listServerFileOps.mockRejectedValueOnce(broke); + renderFiles(); + + const ops = await screen.findByRole("region", { name: t("files:ops_label") }); + expect(within(ops).getByText(i18next.t("files:ops_refresh_failed", { reason: humanizeError(broke) }))).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: t("files:refresh") })); + + await waitFor(() => expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull()); + }); + + function watchLinks() { + const clicked: { href: string | null; download: string; hidden: boolean; connected: boolean }[] = []; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) { + clicked.push({ href: this.getAttribute("href"), download: this.download, hidden: this.hidden, connected: this.isConnected }); + }); + return clicked; + } + // readyAfter answers "pending" for the first n reads of the ticket, then "ready". + function readyAfter(n: number) { + let reads = 0; + mocks.exportStatus.mockImplementation(async () => ({ state: reads++ < n ? "pending" : "ready" })); + } + const exportPoll = () => act(() => vi.advanceTimersByTimeAsync(EXPORT_POLL_MS)); + + it("hands a file to the browser once it is ready, holding every change while it is prepared", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const clicked = watchLinks(); + mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "server.properties" }); + mocks.exportDownloadURL.mockResolvedValue("/api/v1/exports/t1/download"); + mocks.uploadServerFile.mockResolvedValue({ path: "a.jar", status: "uploaded", sha256: "a", size: 5 }); + readyAfter(1); + renderFiles(); + await screen.findByText("Pack.ZIP"); + + fireEvent.click(button("download_item", "server.properties")); + + await waitFor(() => expect(mocks.downloadServerFile.mock.calls).toEqual([["lobby", "server.properties", false]])); + expect(button("download_item", "server.properties").title).toBe( + i18next.t("files:download_preparing", { name: "server.properties" }), + ); + for (const b of [button("new_file"), button("download_folder_item", "world"), button("delete_item", "world")]) { + expect(b.disabled).toBe(true); + expect(b.title).toBe(t("files:wait_for_download")); + } + fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [new File(["bytes"], "a.jar")] } }); + await exportPoll(); + expect(mocks.exportStatus.mock.calls).toEqual([["t1"]]); + expect(clicked).toEqual([]); + expect(mocks.uploadServerFile).not.toHaveBeenCalled(); + + await exportPoll(); + + expect(clicked).toEqual([{ href: "/api/v1/exports/t1/download", download: "server.properties", hidden: true, connected: true }]); + expect(await screen.findByText(i18next.t("files:download_started_props", { filename: "server.properties" }))).toBeTruthy(); + expect(button("new_file").disabled).toBe(false); + await waitFor(() => expect(mocks.uploadServerFile.mock.calls.map((c) => c[1])).toEqual(["a.jar"])); + }); + + it("abandons a download the page left before it was ready", async () => { + const clicked = watchLinks(); + mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "world.zip" }); + readyAfter(0); + const { unmount } = renderFiles(); + await screen.findByText("Pack.ZIP"); + vi.useFakeTimers(); + await act(async () => { + fireEvent.click(button("download_folder_item", "world")); + }); + + unmount(); + await exportPoll(); + + expect(mocks.exportStatus).not.toHaveBeenCalled(); + expect(clicked).toEqual([]); + }); + + it.each([ + ["config", "download_started_config"], + ["world", "download_started"], + ])("downloads the folder %s as a zip, and says what it leaves out", async (folder, note) => { + watchLinks(); + mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: `lobby-${folder}.zip` }); + mocks.exportDownloadURL.mockResolvedValue("/api/v1/exports/t1/download"); + readyAfter(0); + renderFiles(); + await screen.findByText("Pack.ZIP"); + vi.useFakeTimers(); + + await act(async () => { + fireEvent.click(button("download_folder_item", folder)); + }); + await exportPoll(); + + expect(mocks.downloadServerFile.mock.calls).toEqual([["lobby", folder, true]]); + expect(screen.getByText(i18next.t(`files:${note}`, { filename: `lobby-${folder}.zip` }))).toBeTruthy(); + }); + + it("offers no download of the proxy secret, and one of the files beside it", async () => { + renderFiles(); + fireEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "config" }) })); + await screen.findByText("bukkit.yml"); + + expect(button("download_item", "paper-global.yml").disabled).toBe(true); + expect(button("download_item", "paper-global.yml").title).toBe(t("files:secret_config_no_download")); + expect(button("download_item", "bukkit.yml").disabled).toBe(false); + }); + + it.each([ + [ + "refused as too many", + () => mocks.downloadServerFile.mockRejectedValue({ status: 429, code: "export_busy", message: "one at a time" }), + () => t("files:download_busy"), + ], + [ + "refused otherwise", + () => mocks.downloadServerFile.mockRejectedValue({ status: 409, code: "maintenance_in_progress", message: "a backup holds the world" }), + () => + i18next.t("files:download_failed_because", { + reason: humanizeError({ status: 409, code: "maintenance_in_progress", message: "a backup holds the world" }), + }), + ], + [ + "failed with a reason", + () => mocks.exportStatus.mockResolvedValue({ state: "failed", message: "tar: world: Cannot open" }), + () => i18next.t("files:download_failed_because", { reason: "tar: world: Cannot open" }), + ], + ["failed without one", () => mocks.exportStatus.mockResolvedValue({ state: "failed" }), () => t("files:download_failed")], + ])("says why a download could not be prepared when %s", async (_label, arrange, words) => { + const clicked = watchLinks(); + mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "world.zip" }); + arrange(); + renderFiles(); + await screen.findByText("Pack.ZIP"); + vi.useFakeTimers(); + + await act(async () => { + fireEvent.click(button("download_folder_item", "world")); + }); + await exportPoll(); + + expect(screen.getByText(words())).toBeTruthy(); + expect(clicked).toEqual([]); + expect(mocks.exportDownloadURL).not.toHaveBeenCalled(); + expect(button("download_folder_item", "world").disabled).toBe(false); + }); +}); diff --git a/panel/src/pages/ServerFiles.tsx b/panel/src/pages/ServerFiles.tsx index d1cd1dc..2dcab63 100644 --- a/panel/src/pages/ServerFiles.tsx +++ b/panel/src/pages/ServerFiles.tsx @@ -4,6 +4,8 @@ import { AlertTriangle, ArrowUp, ChevronRight, + Download, + FileArchive, FilePlus, FileText, Folder, @@ -25,8 +27,10 @@ import { MessageLine } from "@/components/MessageLine"; import { InlineConfirm } from "@/components/InlineConfirm"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { NameDialog } from "@/components/files/NameDialog"; +import { FileOps } from "@/components/files/FileOps"; import { UploadQueue } from "@/components/files/UploadQueue"; -import { MAX_UPLOAD_BYTES, useUploads } from "@/components/files/useUploads"; +import { useFileOps } from "@/components/files/useFileOps"; +import { useUploads } from "@/components/files/useUploads"; import { SECRET_CONFIG_PATH, isManaged, @@ -51,8 +55,9 @@ import { api, humanizeError } from "@/lib/api"; import { STATUS_POLL_FAST_MS, useAsync, usePolling, useUnsavedGuard } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import { canManage, ownershipPending } from "@/lib/ownership"; +import { awaitExport, saveDownload } from "@/lib/download"; import { formatBytes, formatRelative } from "@/lib/format"; -import type { ServerFileEntry } from "@/lib/types"; +import type { FileOp, ServerFileEntry } from "@/lib/types"; import { cn } from "@/lib/utils"; /** The write ceiling, mirrored from fileedit.MaxWriteBytes (server truth). Reads @@ -91,6 +96,12 @@ function decodeText(bytes: Uint8Array): string | null { } } +/** isZip says whether a file is one the page offers to extract: only .zip is, + * since extraction reads the zip format alone. */ +function isZip(name: string): boolean { + return name.toLowerCase().endsWith(".zip"); +} + /** The name questions the page asks, each tied to the folder it was asked in. */ type Naming = | { kind: "file" | "folder"; dir: string } @@ -121,6 +132,9 @@ export function ServerFiles() { const [truncated, setTruncated] = useState(false); const [listErr, setListErr] = useState(null); const [listLoading, setListLoading] = useState(false); + // Bytes free on the world volume by the latest listing; null while unknown + // (the Job reports 0 when it could not tell). + const [free, setFree] = useState(null); const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null); // Every load takes a ticket and only the newest one lands. The rows and the @@ -137,6 +151,7 @@ export function ServerFiles() { if (ticket !== loadSeq.current) return; setEntries(sortEntries(r.entries ?? [])); setTruncated(r.truncated === true); + setFree(r.free_bytes > 0 ? r.free_bytes : null); setDir(p); } catch (e) { if (ticket !== loadSeq.current) return; @@ -275,12 +290,59 @@ export function ServerFiles() { } } + // A background op (an extraction, or a file landing from parts) holds the + // world until it ends, and goes on with the page closed. One that succeeded + // changed the folder, so the listing is reread; an extraction started here + // that stopped at files it would replace asks about them at once. + const startedHere = useRef(new Set()); + const [conflicts, setConflicts] = useState(null); + const [conflictsOpen, setConflictsOpen] = useState(false); + function showConflicts(op: FileOp) { + setConflicts(op); + setConflictsOpen(true); + } + function opEnded(op: FileOp) { + if (op.state === "succeeded") void load(dir); + else if (op.op === "unzip" && op.error?.code === "file_exists" && startedHere.current.has(op.id)) { + showConflicts(op); + } + } + const fileOps = useFileOps(name, owned && stopped, opEnded); + + // A download holds the world while felis-api gets it ready, and a change + // sent meanwhile could only be refused. + const [downloading, setDownloading] = useState(null); + const [unzipping, setUnzipping] = useState(null); + const alive = useRef(true); + useEffect( + () => () => { + alive.current = false; + }, + [], + ); + // Uploads run one at a time. The listing is reread once the queue has drained // rather than after each file: every listing is a Job of its own. const landedSince = useRef(false); - const uploads = useUploads(name, () => { - landedSince.current = true; + const uploads = useUploads(name, { + onLanded: () => { + landedSince.current = true; + }, + onOp: fileOps.ignore, + hold: fileOps.running || downloading !== null || unzipping !== null, + free, }); + // Each change is a Job holding the world lock, so while anything else holds + // it a change could only be refused. + const changing = uploads.busy || fileOps.running || downloading !== null || unzipping !== null; + // Names what holds the lock now: queued uploads wait on an op or a download + // too, so those come first. + const waitTitle = + fileOps.running || unzipping !== null + ? t("wait_for_op") + : downloading !== null + ? t("wait_for_download") + : t("wait_for_uploads"); useEffect(() => { if (uploads.busy || !landedSince.current) return; landedSince.current = false; @@ -384,6 +446,76 @@ export function ServerFiles() { void load(dir); } + // unzip starts extracting the archive at p into its own folder. Without + // overwrite the op stops before touching anything when a file would be + // replaced, and names those files. + async function unzip(p: string, overwrite: boolean) { + const { op } = await api.unzipServerFile(name, p, overwrite); + startedHere.current.add(op.id); + fileOps.started(op); + if (op.state !== "running") opEnded(op); + } + + async function startUnzip(entry: ServerFileEntry) { + const p = joinPath(dir, entry.name); + setMsg(null); + setUnzipping(p); + try { + await unzip(p, false); + } catch (e) { + setMsg({ kind: "error", text: humanizeError(e) }); + } finally { + setUnzipping(null); + } + } + + async function overwriteConflicts() { + if (!conflicts) return; + await unzip(conflicts.path, true); + fileOps.dismiss(conflicts.id); + } + + // download has felis-api get the file, or the folder as a .zip, ready and + // hands it to the browser, as a backup download does. The two secrets never + // leave: server.properties comes with rcon.password redacted, and the + // folder holding paper-global.yml comes without it. + async function download(entry: ServerFileEntry) { + const p = joinPath(dir, entry.name); + setMsg(null); + setDownloading(p); + try { + const tk = await api.downloadServerFile(name, p, entry.is_dir); + const s = await awaitExport(tk.ticket, () => alive.current); + if (s === null) return; + if (s.state === "failed") { + setMsg({ + kind: "error", + text: s.message ? t("download_failed_because", { reason: s.message }) : t("download_failed"), + }); + return; + } + saveDownload(await api.exportDownloadURL(tk.ticket), tk.filename); + const note = + p === "server.properties" + ? "download_started_props" + : p === parentOf(SECRET_CONFIG_PATH) + ? "download_started_config" + : "download_started"; + setMsg({ kind: "success", text: t(note, { filename: tk.filename }) }); + } catch (e) { + if (!alive.current) return; + setMsg({ + kind: "error", + text: + (e as { code?: string }).code === "export_busy" + ? t("download_busy") + : t("download_failed_because", { reason: humanizeError(e) }), + }); + } finally { + if (alive.current) setDownloading(null); + } + } + const back = ; if (statusQ.loading && !statusQ.data) { return ( @@ -543,15 +675,13 @@ export function ServerFiles() { })}
    - {/* Each change is a Job holding the world lock, so while an - upload holds it a change could only be refused. */}
    + + ev.stopPropagation()}> + {!e.is_dir && isZip(e.name) && ( + + )} + @@ -865,6 +1045,45 @@ export function ServerFiles() { confirmLabel={t("delete")} onConfirm={deleteEntry} /> + + + + ); } + +function conflictCount(op: FileOp | null): number { + return op?.error?.conflict_count ?? op?.error?.conflicts?.length ?? 0; +} + +// ConflictList names the files an extraction would replace. The op carries the +// first ones and the full count; the rest are counted. +function ConflictList({ op }: { op: FileOp | null }) { + const { t } = useTranslation("files"); + const listed = op?.error?.conflicts ?? []; + const more = conflictCount(op) - listed.length; + if (listed.length === 0) return null; + return ( +
    +
      + {listed.map((path) => ( +
    • + {path} +
    • + ))} +
    + {more > 0 &&

    {t("unzip_conflicts_more", { count: more })}

    } +
    + ); +}