diff --git a/README.md b/README.md index d240bd3..386de31 100644 --- a/README.md +++ b/README.md @@ -218,9 +218,10 @@ All configuration is YAML. - If no unit is provided, value is interpreted as bytes - Examples: `5MB`, `100MiB`, `1GiB`, `1048576` - Default: `100MiB` -- `default_ttl`: expiration applied to new scratches. +- `default_ttl`: expiration applied to new scratches when `POST /api/scratch` omits `ttl`. - Must be `> 0` and `<= 24h` - Default: `15m` + - Optional per-scratch `ttl` is a Go duration from `1m` through `24h`. Multipart uploads use form field `ttl`; raw-body POSTs use query `ttl`. Invalid or out-of-range values return 400. Infinite TTL is not allowed. - `raw_cache_max_size`: max total decompressed bytes cached in memory for `/api/raw` range requests. - Supported units: `B`, `KB`, `MB`, `GB`, `KiB`, `MiB`, `GiB` (case-insensitive) - Must be `> 0` diff --git a/internal/config/config.go b/internal/config/config.go index 70f7558..a41d8cb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,9 +44,12 @@ const ( defaultAPIWriteRequestsPerMinute = 30 defaultAPIWriteRateLimitBurst = 10 defaultHSTSEnabled = true - maxDefaultTTLLimit = 24 * time.Hour - minCleanupInterval = 10 * time.Second - envPrefix = "SCRATCHBOX_" + // MinScratchTTL / MaxScratchTTL bound optional per-scratch TTL on POST /api/scratch + // and also cap limits.default_ttl. + MinScratchTTL = time.Minute + MaxScratchTTL = 24 * time.Hour + minCleanupInterval = 10 * time.Second + envPrefix = "SCRATCHBOX_" ) // envSuffixes is the single source of truth for every SCRATCHBOX_* environment @@ -138,7 +141,7 @@ type AccessLogConfig struct { type LimitsConfig struct { MaxUploadSize string `yaml:"max_upload_size" comment:"Maximum upload request body size. Supports B, KB, MB, GB and KiB, MiB, GiB (case-insensitive). Defaults to bytes when unit is omitted (example: 1048576)."` - DefaultTTL string `yaml:"default_ttl" comment:"Default expiration for new scratches. Must be > 0 and <= 24h."` + DefaultTTL string `yaml:"default_ttl" comment:"Default expiration for new scratches when POST /api/scratch omits ttl. Must be > 0 and <= 24h."` RawCacheMaxSize string `yaml:"raw_cache_max_size" comment:"Maximum total decompressed bytes cached in memory for /api/raw range requests. Supports B, KB, MB, GB and KiB, MiB, GiB. Set this above limits.max_upload_size based on your expected concurrent large downloads."` RawCacheMaxEntries int `yaml:"raw_cache_max_entries" comment:"Maximum number of decompressed /api/raw entries retained in memory cache. Use this as a secondary cap alongside raw_cache_max_size."` MaxUploadSizeBytes int64 `yaml:"-"` @@ -326,8 +329,8 @@ func (c *Config) Validate() error { if err != nil { return fmt.Errorf("limits.default_ttl invalid: %w", err) } - if ttl <= 0 || ttl > maxDefaultTTLLimit { - return fmt.Errorf("limits.default_ttl must be in (0, %s]", maxDefaultTTLLimit) + if ttl <= 0 || ttl > MaxScratchTTL { + return fmt.Errorf("limits.default_ttl must be in (0, %s]", MaxScratchTTL) } c.Limits.DefaultTTLDuration = ttl diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 416fb4f..7817749 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -106,6 +106,7 @@ func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) { "max_upload_size_bytes": s.cfg.Limits.MaxUploadSizeBytes, "upload_allowed": s.isUploadAllowedForRequest(r), "default_ttl": s.cfg.Limits.DefaultTTL, + "max_ttl": config.MaxScratchTTL.String(), } writeJSON(w, http.StatusOK, payload) } @@ -184,8 +185,9 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) { var reader io.Reader originalName := "" contentType := strings.TrimSpace(r.Header.Get("Content-Type")) + multipart := strings.HasPrefix(contentType, "multipart/form-data") - if strings.HasPrefix(contentType, "multipart/form-data") { + if multipart { if err := r.ParseMultipartForm(multipartMaxMemory); err != nil { s.writeCreateError(w, err) return @@ -228,7 +230,13 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) { } } - meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration) + ttl, err := s.resolveTTL(r, multipart) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, ttl) if err != nil { if strings.Contains(err.Error(), "request body too large") { http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge) @@ -364,6 +372,33 @@ func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, meta.ID, meta.CreatedAt, bytes.NewReader(content)) } +// resolveTTL returns the per-scratch TTL for POST /api/scratch. +// Multipart uploads read form field "ttl"; raw-body uploads read query "ttl". +// Omitted or empty values use limits.default_ttl. Invalid or out-of-range +// values (below 1m, above MaxScratchTTL, or non-duration) return an error. +// Infinite TTL is not allowed. +func (s *Server) resolveTTL(r *http.Request, multipart bool) (time.Duration, error) { + raw := "" + if multipart { + raw = strings.TrimSpace(r.FormValue("ttl")) + } else { + raw = strings.TrimSpace(r.URL.Query().Get("ttl")) + } + if raw == "" { + return s.cfg.Limits.DefaultTTLDuration, nil + } + parsed, err := time.ParseDuration(raw) + if err != nil { + return 0, errInvalidScratchTTL + } + if parsed < config.MinScratchTTL || parsed > config.MaxScratchTTL { + return 0, errInvalidScratchTTL + } + return parsed, nil +} + +var errInvalidScratchTTL = fmt.Errorf("ttl must be a Go duration between %s and %s", config.MinScratchTTL, config.MaxScratchTTL) + func (s *Server) writeCreateError(w http.ResponseWriter, err error) { var maxErr *http.MaxBytesError if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") { diff --git a/internal/http/handlers_integration_ttl_test.go b/internal/http/handlers_integration_ttl_test.go new file mode 100644 index 0000000..859b9f8 --- /dev/null +++ b/internal/http/handlers_integration_ttl_test.go @@ -0,0 +1,217 @@ +package httpapi + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestCreateScratchTTLQueryOmitUsesDefault(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: 15 * time.Minute, + rateLimitEnable: false, + }) + + before := time.Now().UTC() + req := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("omit-ttl")) + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + req.RemoteAddr = "127.0.0.1:8101" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + expiresAt, err := time.Parse(time.RFC3339Nano, payload["expires_at"].(string)) + if err != nil { + // JSON may marshal as RFC3339 + expiresAt, err = time.Parse(time.RFC3339, payload["expires_at"].(string)) + if err != nil { + t.Fatalf("parse expires_at %v (%T): %v", payload["expires_at"], payload["expires_at"], err) + } + } + delta := expiresAt.Sub(before) + if delta < 14*time.Minute || delta > 16*time.Minute { + t.Fatalf("expires delta = %s, want ~15m", delta) + } +} + +func TestCreateScratchTTLQueryMinMaxAndInvalid(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: 15 * time.Minute, + rateLimitEnable: false, + }) + + cases := []struct { + name string + ttl string + wantStatus int + wantDelta time.Duration + deltaSlack time.Duration + }{ + {name: "min", ttl: "1m", wantStatus: http.StatusCreated, wantDelta: time.Minute, deltaSlack: 15 * time.Second}, + {name: "max", ttl: "24h", wantStatus: http.StatusCreated, wantDelta: 24 * time.Hour, deltaSlack: time.Minute}, + {name: "custom", ttl: "2h", wantStatus: http.StatusCreated, wantDelta: 2 * time.Hour, deltaSlack: 30 * time.Second}, + {name: "below-min", ttl: "30s", wantStatus: http.StatusBadRequest}, + {name: "above-max", ttl: "25h", wantStatus: http.StatusBadRequest}, + {name: "invalid", ttl: "not-a-duration", wantStatus: http.StatusBadRequest}, + {name: "zero", ttl: "0s", wantStatus: http.StatusBadRequest}, + {name: "negative", ttl: "-5m", wantStatus: http.StatusBadRequest}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + before := time.Now().UTC() + req := httptest.NewRequest(http.MethodPost, "/api/scratch?ttl="+tc.ttl, bytes.NewBufferString("body-"+tc.name)) + req.Header.Set("Content-Type", "text/plain; charset=utf-8") + req.RemoteAddr = "127.0.0.1:8200" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d body=%q", rec.Code, tc.wantStatus, rec.Body.String()) + } + if tc.wantStatus != http.StatusCreated { + if !strings.Contains(rec.Body.String(), "ttl") { + t.Fatalf("expected ttl error message, got %q", rec.Body.String()) + } + return + } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + expiresRaw, _ := payload["expires_at"].(string) + expiresAt, err := time.Parse(time.RFC3339Nano, expiresRaw) + if err != nil { + expiresAt, err = time.Parse(time.RFC3339, expiresRaw) + if err != nil { + t.Fatalf("parse expires_at %q: %v", expiresRaw, err) + } + } + delta := expiresAt.Sub(before) + if delta < tc.wantDelta-tc.deltaSlack || delta > tc.wantDelta+tc.deltaSlack { + t.Fatalf("expires delta = %s, want ~%s", delta, tc.wantDelta) + } + }) + } +} + +func TestCreateScratchTTLMultipartFormField(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: 15 * time.Minute, + rateLimitEnable: false, + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + file, err := writer.CreateFormFile("file", "ttl.txt") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + if _, err := file.Write([]byte("multipart-ttl")); err != nil { + t.Fatalf("write file: %v", err) + } + if err := writer.WriteField("ttl", "5m"); err != nil { + t.Fatalf("WriteField ttl: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + + before := time.Now().UTC() + req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.RemoteAddr = "127.0.0.1:8301" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + expiresRaw, _ := payload["expires_at"].(string) + expiresAt, err := time.Parse(time.RFC3339Nano, expiresRaw) + if err != nil { + expiresAt, err = time.Parse(time.RFC3339, expiresRaw) + if err != nil { + t.Fatalf("parse expires_at %q: %v", expiresRaw, err) + } + } + delta := expiresAt.Sub(before) + if delta < 4*time.Minute || delta > 6*time.Minute { + t.Fatalf("expires delta = %s, want ~5m", delta) + } +} + +func TestCreateScratchTTLMultipartInvalid(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: 15 * time.Minute, + rateLimitEnable: false, + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + file, _ := writer.CreateFormFile("file", "bad-ttl.txt") + _, _ = file.Write([]byte("x")) + _ = writer.WriteField("ttl", "forever") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.RemoteAddr = "127.0.0.1:8302" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestGetUIConfigExposesMaxTTL(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 2048, + defaultTTL: 15 * time.Minute, + rateLimitEnable: false, + }) + + // DefaultTTL string is empty in test helper; set via direct server call already covered. + // Hit routed /api/config and ensure max_ttl is present. + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req.RemoteAddr = "127.0.0.1:8401" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + body := rec.Body.String() + if !strings.Contains(body, `"max_ttl"`) { + t.Fatalf("expected max_ttl in body: %q", body) + } +} diff --git a/internal/http/handlers_unit_test.go b/internal/http/handlers_unit_test.go index 62dcfea..7e74d2c 100644 --- a/internal/http/handlers_unit_test.go +++ b/internal/http/handlers_unit_test.go @@ -7,9 +7,11 @@ import ( "net/http" "net/http/httptest" "net/netip" + "net/url" "path/filepath" "strings" "testing" + "time" "scratchbox/internal/config" "scratchbox/internal/storage" @@ -88,11 +90,76 @@ func TestGetUIConfig(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } - if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`) { + if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`, `"max_ttl":"`+config.MaxScratchTTL.String()+`"`, "default_ttl") { t.Fatalf("unexpected body: %q", got) } } +func TestResolveTTL(t *testing.T) { + t.Parallel() + + s := &Server{ + cfg: config.Config{ + Limits: config.LimitsConfig{ + DefaultTTLDuration: 15 * time.Minute, + }, + }, + } + + cases := []struct { + name string + url string + formTTL string + multipart bool + want time.Duration + wantErr bool + }{ + {name: "omit raw uses default", url: "/api/scratch", want: 15 * time.Minute}, + {name: "empty query uses default", url: "/api/scratch?ttl=", want: 15 * time.Minute}, + {name: "empty form uses default", url: "/api/scratch", multipart: true, formTTL: " ", want: 15 * time.Minute}, + {name: "query min", url: "/api/scratch?ttl=1m", want: time.Minute}, + {name: "query max", url: "/api/scratch?ttl=24h", want: config.MaxScratchTTL}, + {name: "query default value", url: "/api/scratch?ttl=15m", want: 15 * time.Minute}, + {name: "form min", url: "/api/scratch", multipart: true, formTTL: "1m", want: time.Minute}, + {name: "form max", url: "/api/scratch", multipart: true, formTTL: "24h", want: config.MaxScratchTTL}, + {name: "form default value", url: "/api/scratch", multipart: true, formTTL: "15m", want: 15 * time.Minute}, + {name: "invalid duration", url: "/api/scratch?ttl=nope", wantErr: true}, + {name: "below min", url: "/api/scratch?ttl=59s", wantErr: true}, + {name: "above max", url: "/api/scratch?ttl=24h1s", wantErr: true}, + {name: "zero", url: "/api/scratch?ttl=0", wantErr: true}, + {name: "negative", url: "/api/scratch?ttl=-1m", wantErr: true}, + {name: "form invalid", url: "/api/scratch", multipart: true, formTTL: "forever", wantErr: true}, + {name: "form below min", url: "/api/scratch", multipart: true, formTTL: "30s", wantErr: true}, + {name: "form above max", url: "/api/scratch", multipart: true, formTTL: "25h", wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodPost, tc.url, nil) + if tc.multipart { + req.Form = url.Values{} + if tc.formTTL != "" { + req.Form.Set("ttl", tc.formTTL) + } + } + got, err := s.resolveTTL(req, tc.multipart) + if tc.wantErr { + if err == nil { + t.Fatalf("resolveTTL() error = nil, want error (got %s)", got) + } + return + } + if err != nil { + t.Fatalf("resolveTTL() error = %v", err) + } + if got != tc.want { + t.Fatalf("resolveTTL() = %s, want %s", got, tc.want) + } + }) + } +} + func TestGetUIConfigUploadDenied(t *testing.T) { t.Parallel() diff --git a/web/ui/src/App.svelte b/web/ui/src/App.svelte index d9da6a3..b1cb43d 100644 --- a/web/ui/src/App.svelte +++ b/web/ui/src/App.svelte @@ -7,6 +7,8 @@ let maxUploadSizeBytes = $state(0); let defaultTTL = $state("15m"); + let maxTTL = $state("24h"); + let selectedTTL = $state("15m"); let uploadAllowed = $state(true); let configError = $state(""); @@ -29,6 +31,8 @@ const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true)); const defaultTTLDisplay = $derived(formatTTLDisplay(defaultTTL)); + const maxTTLDisplay = $derived(formatTTLDisplay(maxTTL)); + const ttlOptions = $derived(buildTTLOptions(defaultTTL, maxTTL)); const viewPageTitle = $derived( viewMeta?.filename @@ -101,11 +105,15 @@ maxUploadSizeBytes = Number.isFinite(max) ? max : 0; uploadAllowed = Boolean(data.upload_allowed); defaultTTL = String(data.default_ttl ?? "").trim() || "15m"; + maxTTL = String(data.max_ttl ?? "").trim() || "24h"; + selectedTTL = defaultTTL; } catch (err) { configError = err instanceof Error ? err.message : String(err); maxUploadSizeBytes = 0; uploadAllowed = true; defaultTTL = "15m"; + maxTTL = "24h"; + selectedTTL = defaultTTL; } } @@ -305,6 +313,85 @@ } }); + function parseTTLToSeconds(rawTTL) { + const source = String(rawTTL ?? "").trim().toLowerCase(); + if (!source) { + return NaN; + } + const matches = Array.from(source.matchAll(/(\d+)\s*([hms])/g)); + if (matches.length === 0) { + return NaN; + } + let total = 0; + for (const match of matches) { + const value = Number.parseInt(match[1], 10); + if (!Number.isFinite(value) || value < 0) { + continue; + } + const unit = match[2]; + if (unit === "h") { + total += value * 3600; + } else if (unit === "m") { + total += value * 60; + } else if (unit === "s") { + total += value; + } + } + return total; + } + + function buildTTLOptions(defaultRaw, maxRaw) { + const presets = ["1m", "5m", "15m", "30m", "1h", "6h", "12h", "24h"]; + const maxSeconds = parseTTLToSeconds(maxRaw); + const defaultSeconds = parseTTLToSeconds(defaultRaw); + const seen = new Set(); + const options = []; + + function addOption(value) { + const seconds = parseTTLToSeconds(value); + if (!Number.isFinite(seconds) || seconds < 60) { + return; + } + if (Number.isFinite(maxSeconds) && seconds > maxSeconds) { + return; + } + if (seen.has(seconds)) { + return; + } + seen.add(seconds); + options.push({ + value, + label: formatTTLDisplay(value), + seconds + }); + } + + for (const preset of presets) { + addOption(preset); + } + addOption(defaultRaw); + addOption(maxRaw); + + options.sort((a, b) => a.seconds - b.seconds); + + // Ensure selected/default is present even if parsing failed above. + if (defaultRaw && !options.some((opt) => opt.value === defaultRaw)) { + options.unshift({ + value: defaultRaw, + label: formatTTLDisplay(defaultRaw), + seconds: Number.isFinite(defaultSeconds) ? defaultSeconds : 0 + }); + } + + return options.map((opt) => ({ + value: opt.value, + label: + opt.value === defaultRaw + ? `${opt.label} (default)` + : opt.label + })); + } + function expiresInText(expiresAt, now) { const expiry = Date.parse(String(expiresAt ?? "")); if (!Number.isFinite(expiry)) { @@ -393,6 +480,8 @@ try { const formData = new FormData(); formData.append("file", selectedFile); + const ttlValue = String(selectedTTL ?? "").trim() || defaultTTL; + formData.append("ttl", ttlValue); const response = await fetch("/api/scratch", { method: "POST", body: formData @@ -509,7 +598,7 @@ {:else if routeMode === "upload"}

Size limit: {maxUploadSizeDisplay}

-

Expiration: {defaultTTLDisplay}

+

Default expiration: {defaultTTLDisplay} (max {maxTTLDisplay})

{#if configError}

Could not load UI config: {configError}

{/if} @@ -530,6 +619,20 @@ />
+
+ + +
+ @@ -657,7 +760,8 @@ margin-top: 1rem; } - input[type="file"] { + input[type="file"], + select { margin-top: 0.5rem; width: 100%; background: #121826;