8 Commits

Author SHA1 Message Date
s1d3sw1ped_bot 9fc0a50a82 Merge pull request 'Promote develop: per-scratch TTL' (#6) from develop into master
Release Artifacts / Validate release tag (push) Successful in 2s
Release Artifacts / Build and release executables (push) Successful in 31s
Release Artifacts / Build and release Docker image (push) Successful in 29s
Format / gofmt (push) Successful in 13s
CI / Build (push) Successful in 21s
CI / Go Tests (push) Successful in 23s
Promote per-scratch TTL to default branch.
2026-09-01 16:23:22 -05:00
s1d3sw1ped_bot 8edd6550d6 Merge pull request 'api/ui: Allow per-scratch TTL down to 1m' (#5) from api-ui-per-scratch-ttl into develop
Format / gofmt (pull_request) Successful in 29s
Format / gofmt (push) Successful in 30s
CI / Build (pull_request) Successful in 37s
CI / Build (push) Successful in 37s
CI / Go Tests (push) Successful in 39s
CI / Go Tests (pull_request) Successful in 40s
Optional per-scratch TTL (1m–24h) on API and upload UI.

#4
2026-09-01 16:22:31 -05:00
s1d3sw1ped_bot ed95ce8295 api/ui: Allow per-scratch TTL down to 1m
Format / gofmt (push) Successful in 26s
Format / gofmt (pull_request) Successful in 26s
CI / Build (push) Successful in 33s
CI / Build (pull_request) Successful in 33s
CI / Go Tests (push) Successful in 35s
CI / Go Tests (pull_request) Successful in 35s
#4
2026-09-01 21:21:17 +00:00
s1d3sw1ped_bot 430a6f67cf docs: Add CONTRIBUTING.md
Format / gofmt (push) Successful in 16s
CI / Build (push) Successful in 21s
CI / Go Tests (push) Successful in 22s
2026-09-01 13:58:31 -05:00
s1d3sw1ped_bot 6d88526d48 docs: Add CONTRIBUTING.md
CI / Go Tests (pull_request) Successful in 22s
CI / Build (pull_request) Successful in 23s
Format / gofmt (pull_request) Successful in 11s
Format / gofmt (push) Successful in 13s
CI / Go Tests (push) Successful in 22s
CI / Build (push) Successful in 23s
Point new contributors at develop for PRs, kernel-style commit
subjects, What/Why/Test PR bodies, and safe issue citations under
Gitea merge-text closing rules.
2026-09-01 18:56:11 +00:00
s1d3sw1ped_bot 1d301d29b6 docs: Add CONTRIBUTING.md
Format / gofmt (push) Successful in 13s
CI / Build (push) Successful in 22s
CI / Go Tests (push) Successful in 26s
Contributors need a short guide for develop-targeted PRs, commit
subject form, and Gitea issue-closing rules.
2026-09-01 13:37:42 -05:00
s1d3sw1ped_bot 6379fac8f6 Bump Go to 1.27.0 (#1)
Format / gofmt (push) Successful in 13s
CI / Build (push) Successful in 22s
CI / Go Tests (push) Successful in 24s
2026-08-31 20:11:24 -05:00
s1d3sw1ped 90f1cf8bdf Enhance environment variable documentation and configuration
Format / gofmt (push) Successful in 5s
CI / Build (push) Successful in 12s
CI / Go Tests (push) Successful in 22s
Release Artifacts / Validate release tag (push) Successful in 1s
Release Artifacts / Build and release executables (push) Successful in 33s
Release Artifacts / Build and release Docker image (push) Successful in 3m10s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 16:27:18 -05:00
17 changed files with 762 additions and 30 deletions
+21
View File
@@ -1,26 +1,38 @@
# Scratchbox env-mode example for `scratchbox server --env`.
# Set values here and pass this file with `--env-file .env`.
#
# The complete list of supported SCRATCHBOX_* env vars is the single source of
# truth in internal/config/config.go (var envSuffixes). See the comment on
# envSuffixes for maintenance rules when adding new vars.
#
# IMPORTANT:
# - Scratchbox stores its key at $SCRATCHBOX_STORAGE_DATA_DIR/metadata.key.
# - Keep that data directory persistent across restarts.
# server
SCRATCHBOX_SERVER_LISTEN_ADDR=:8080
SCRATCHBOX_SERVER_READ_HEADER_TIMEOUT=5s
# Body/response deadlines. 0 = disabled. Raise or set 0 when LIMITS_MAX_UPLOAD_SIZE is multi-GiB.
SCRATCHBOX_SERVER_READ_TIMEOUT=30s
SCRATCHBOX_SERVER_WRITE_TIMEOUT=30s
SCRATCHBOX_SERVER_IDLE_TIMEOUT=120s
SCRATCHBOX_SERVER_SHUTDOWN_TIMEOUT=10s
SCRATCHBOX_SERVER_MAX_HEADER_BYTES=1048576
SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED=true
SCRATCHBOX_SERVER_ACCESS_LOG_FILE_PATH=
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_SIZE=100MiB
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_BACKUPS=5
# limits
# When raising MAX_UPLOAD_SIZE to multi-GiB, also set READ/WRITE_TIMEOUT high or 0 (see above).
SCRATCHBOX_LIMITS_MAX_UPLOAD_SIZE=100MiB
SCRATCHBOX_LIMITS_DEFAULT_TTL=15m
# In-memory cache for small /api/raw range hits. Larger scratches stream from disk.
SCRATCHBOX_LIMITS_RAW_CACHE_MAX_SIZE=200MiB
SCRATCHBOX_LIMITS_RAW_CACHE_MAX_ENTRIES=32
# storage
SCRATCHBOX_STORAGE_DATA_DIR=./data
SCRATCHBOX_STORAGE_CLEANUP_INTERVAL=1m
# security
@@ -29,12 +41,21 @@ SCRATCHBOX_SECURITY_ALLOWED_IPS=127.0.0.1 # This should be set to your networks
SCRATCHBOX_SECURITY_TRUST_PROXY_HEADERS=false
# Comma-separated IP/CIDR list.
SCRATCHBOX_SECURITY_TRUSTED_PROXY_IPS= # Same as SCRATCHBOX_SECURITY_ALLOWED_IPS just Trusted Proxies you expect clients to come from instead. Must be *immediate* reverse proxies only; avoid 0/0.
# Per-client token bucket on UI routes (/, /u, /s/{id}).
SCRATCHBOX_SECURITY_RATE_LIMIT_UI_ENABLED=false
SCRATCHBOX_SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE=360
SCRATCHBOX_SECURITY_RATE_LIMIT_UI_BURST=120
# Per-client token bucket on API read routes (GET /api/config, GET /api/scratch/{id}, GET /api/raw/{id}).
SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_ENABLED=false
SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE=300
SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_BURST=100
# Per-client token bucket on API write route (POST /api/scratch).
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_ENABLED=true
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE=30
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_BURST=10
# Enable Strict-Transport-Security (HSTS) header (recommended for public TLS deploys).
SCRATCHBOX_SECURITY_HSTS_ENABLED=true
+3 -1
View File
@@ -12,4 +12,6 @@
/web/ui/node_modules/
# Frontend build artifacts
/web/static/
/web/static/
# Local/large test fixtures (do not commit)
/testdata/*.zip
+44
View File
@@ -0,0 +1,44 @@
# Contributing
## Propose changes
Open a pull request against `develop`. Keep the default branch for releases and
stable tips; land work on `develop` first.
Point at an existing issue when one fits. Prefer a short issue that states the
symptom or request before a large PR.
## Commits
Subject form:
```
area: Imperative summary
```
- **Area** is a real package, directory, or subsystem token (`ci:`, `docs:`,
Go package name). Not a lone filename.
- **Imperative** mood: Fix, Add, Remove — not "Fixed" or "This patch…".
- No trailing period. Aim ≤ ~7075 characters for the whole subject.
- Not conventional-commits (`feat:` / `fix:` / `chore:` as types).
Body explains **why**. Establish the problem, then say what you are doing.
One logical change per commit; split fix and cleanup.
## Pull requests
Title matches the primary commit subject.
- **What** changed
- **Why** (problem and impact)
- **Test** (concrete steps; "CI green" alone is weak)
## Issues and closing
Cite leftover issues by **full URL**. Gitea closes issues when `#N` appears in
merge text, so do not put `#N` in the merge message unless that issue is actually
done. Use `Fixes #N` / `Closes #N` only when the leftover work is finished.
## License
License TBD by owner.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25.4-alpine AS builder
FROM golang:1.27.0-alpine AS builder
WORKDIR /src
+13 -7
View File
@@ -64,7 +64,7 @@ Storage encryption key behavior:
- On subsequent startups, Scratchbox reuses the same key file from that data directory.
- Keep `storage.data_dir` persistent; losing or replacing `metadata.key` makes existing scratches unreadable.
Environment variables supported by `scratchbox server --env` (note: when `--env` is used, the variables and their values are printed to stdout on startup for diagnostics, with values of IP allow/trusted lists redacted):
Environment variables supported by `scratchbox server --env` (note: when `--env` is used, the variables and their values are printed to stdout on startup for diagnostics, with values of IP allow/trusted lists redacted). The canonical list of supported vars is envSuffixes in internal/config/config.go — see the comment there for how to keep this documentation in sync when adding new configuration.
- `SCRATCHBOX_SERVER_LISTEN_ADDR`
- `SCRATCHBOX_SERVER_READ_HEADER_TIMEOUT`
@@ -97,7 +97,7 @@ Environment variables supported by `scratchbox server --env` (note: when `--env`
- `SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_BURST`
- `SCRATCHBOX_SECURITY_HSTS_ENABLED`
Example `.env` for Docker:
Example `.env` for Docker (must be kept in sync with envSuffixes in internal/config/config.go):
```dotenv
# server
@@ -109,6 +109,9 @@ SCRATCHBOX_SERVER_IDLE_TIMEOUT=120s
SCRATCHBOX_SERVER_SHUTDOWN_TIMEOUT=10s
SCRATCHBOX_SERVER_MAX_HEADER_BYTES=1048576
SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED=true
SCRATCHBOX_SERVER_ACCESS_LOG_FILE_PATH=
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_SIZE=100MiB
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_BACKUPS=5
# limits
SCRATCHBOX_LIMITS_MAX_UPLOAD_SIZE=100MiB
@@ -180,11 +183,13 @@ All configuration is YAML.
- `read_header_timeout`: maximum time for reading request headers.
- Must be `> 0`
- Default: `5s`
- `read_timeout`: maximum time for reading the full request.
- Must be `> 0`
- `read_timeout`: maximum time for reading the full request (including body).
- Must be `>= 0`. `0` disables the deadline (same as Go `http.Server`).
- Raise this (or set `0`) when `limits.max_upload_size` is large or clients are slow: a 30s body timeout cannot complete multi-GiB uploads.
- Default: `30s`
- `write_timeout`: maximum time allowed for writing the response.
- Must be `> 0`
- `write_timeout`: maximum time allowed for writing the response (also bounds slow request handling after headers are read).
- Must be `>= 0`. `0` disables the deadline.
- Raise this (or set `0`) for large uploads/downloads; after request headers are read the write deadline covers the rest of the handler.
- Default: `30s`
- `idle_timeout`: maximum keep-alive idle time between requests.
- Must be `> 0`
@@ -213,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`
+1
View File
@@ -135,6 +135,7 @@ func TestMainProcessEnvSuccess(t *testing.T) {
cmd.Env = append(os.Environ(),
"GO_WANT_MAIN_HELPER=1",
"MAIN_HELPER_MODE=env-success",
// These are from the canonical envSuffixes list (internal/config/config.go)
"SCRATCHBOX_SERVER_LISTEN_ADDR=127.0.0.1:0",
"SCRATCHBOX_STORAGE_DATA_DIR="+filepath.Join(tmp, "data"),
"SCRATCHBOX_SECURITY_ALLOWED_IPS=",
+3 -1
View File
@@ -34,7 +34,7 @@ func newServerCmd() *cobra.Command {
},
}
cmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
cmd.Flags().BoolVar(&useEnv, "env", false, "load config from SCRATCHBOX_* environment variables")
cmd.Flags().BoolVar(&useEnv, "env", false, "load config from SCRATCHBOX_* environment variables (see envSuffixes in internal/config/config.go)")
cmd.MarkFlagsMutuallyExclusive("config", "env")
return cmd
}
@@ -134,6 +134,8 @@ func run(configPath string, useEnv bool) error {
}
func printEnvStartup() int {
// All SCRATCHBOX_* variables are defined by envSuffixes (single source of truth)
// in internal/config/config.go. See the comment there for update rules.
entries := make([]string, 0)
for _, item := range os.Environ() {
if strings.HasPrefix(item, "SCRATCHBOX_") {
+4
View File
@@ -1,6 +1,10 @@
#!/bin/sh
set -eu
# This script forces certain SCRATCHBOX_* vars for Docker.
# The full list of supported env vars (single source of truth) is in
# internal/config/config.go:envSuffixes . Update that list + docs when changing env support.
# Container-enforced data directory.
SCRATCHBOX_STORAGE_DATA_DIR=/data
export SCRATCHBOX_STORAGE_DATA_DIR
+1 -1
View File
@@ -1,6 +1,6 @@
module scratchbox
go 1.25.4
go 1.27.0
require (
github.com/spf13/cobra v1.10.1
+76 -12
View File
@@ -44,11 +44,70 @@ 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
// variable suffix supported by applyEnvOverrides (without the "SCRATCHBOX_" prefix).
//
// Usage sites that hardcode or document these vars MUST have a comment pointing here:
// - applyEnvOverrides (this file)
// - printEnvStartup + flag docs in cmd/scratchbox/server.go
// - docker-entrypoint.sh
// - .env.example
// - README.md (supported list + example block)
// - tests that setenv them (e.g. config_test.go, main_test.go)
//
// When you add, remove, or rename support in applyEnvOverrides (or the corresponding
// struct fields), you MUST:
// 1. Update this list.
// 2. Add/update the pointer comment at every usage site.
// 3. Update .env.example with the new var (plus a sensible default + comment).
// 4. Update the "Environment variables supported by..." list in README.md.
// 5. Update the example .env block in README.md.
//
// A test in config_test.go will fail if .env.example or README.md are missing any
// entry from this list. This prevents the documentation drift that has happened
// in the past when new config fields (e.g. shutdown_timeout, access_log details)
// were added.
var envSuffixes = []string{
"SERVER_LISTEN_ADDR",
"SERVER_READ_HEADER_TIMEOUT",
"SERVER_READ_TIMEOUT",
"SERVER_WRITE_TIMEOUT",
"SERVER_IDLE_TIMEOUT",
"SERVER_SHUTDOWN_TIMEOUT",
"SERVER_MAX_HEADER_BYTES",
"SERVER_ACCESS_LOG_ENABLED",
"SERVER_ACCESS_LOG_FILE_PATH",
"SERVER_ACCESS_LOG_MAX_SIZE",
"SERVER_ACCESS_LOG_MAX_BACKUPS",
"LIMITS_MAX_UPLOAD_SIZE",
"LIMITS_DEFAULT_TTL",
"LIMITS_RAW_CACHE_MAX_SIZE",
"LIMITS_RAW_CACHE_MAX_ENTRIES",
"STORAGE_DATA_DIR",
"STORAGE_CLEANUP_INTERVAL",
"SECURITY_ALLOWED_IPS",
"SECURITY_TRUST_PROXY_HEADERS",
"SECURITY_TRUSTED_PROXY_IPS",
"SECURITY_RATE_LIMIT_UI_ENABLED",
"SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE",
"SECURITY_RATE_LIMIT_UI_BURST",
"SECURITY_RATE_LIMIT_API_READ_ENABLED",
"SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE",
"SECURITY_RATE_LIMIT_API_READ_BURST",
"SECURITY_RATE_LIMIT_API_WRITE_ENABLED",
"SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE",
"SECURITY_RATE_LIMIT_API_WRITE_BURST",
"SECURITY_HSTS_ENABLED",
}
type Config struct {
Server ServerConfig `yaml:"server" comment:"HTTP listener settings."`
Limits LimitsConfig `yaml:"limits" comment:"Upload, expiration, and raw cache defaults."`
@@ -59,8 +118,8 @@ type Config struct {
type ServerConfig struct {
ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."`
ReadHeaderTimeout string `yaml:"read_header_timeout" comment:"Maximum duration for reading request headers (for slowloris protection). Must be > 0."`
ReadTimeout string `yaml:"read_timeout" comment:"Maximum duration for reading the full request, including body. Must be > 0."`
WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response. Must be > 0."`
ReadTimeout string `yaml:"read_timeout" comment:"Maximum duration for reading the full request, including body. Use 0 to disable (recommended when limits.max_upload_size is multi-GiB or clients are slow). Must be >= 0."`
WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response (also bounds slow handlers after headers are read). Use 0 to disable (recommended for large uploads/downloads). Must be >= 0."`
IdleTimeout string `yaml:"idle_timeout" comment:"Maximum amount of time to wait for the next request when keep-alives are enabled. Must be > 0."`
ShutdownTimeout string `yaml:"shutdown_timeout" comment:"Graceful shutdown timeout for the HTTP server. Must be > 0."`
MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."`
@@ -82,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:"-"`
@@ -214,8 +273,9 @@ func (c *Config) Validate() error {
if err != nil {
return fmt.Errorf("server.read_timeout invalid: %w", err)
}
if readTimeout <= 0 {
return errors.New("server.read_timeout must be > 0")
// 0 means no timeout (matches net/http.Server.ReadTimeout). Negative is invalid.
if readTimeout < 0 {
return errors.New("server.read_timeout must be >= 0")
}
c.Server.ReadTimeoutDur = readTimeout
@@ -223,8 +283,9 @@ func (c *Config) Validate() error {
if err != nil {
return fmt.Errorf("server.write_timeout invalid: %w", err)
}
if writeTimeout <= 0 {
return errors.New("server.write_timeout must be > 0")
// 0 means no timeout (matches net/http.Server.WriteTimeout). Negative is invalid.
if writeTimeout < 0 {
return errors.New("server.write_timeout must be >= 0")
}
c.Server.WriteTimeoutDur = writeTimeout
@@ -268,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
@@ -530,6 +591,9 @@ func decodeEncryptionKey(raw string) ([]byte, error) {
return encKey, nil
}
// applyEnvOverrides applies all supported SCRATCHBOX_* vars.
// The canonical list of supported suffixes is in envSuffixes (defined earlier in this file).
// See the comment on envSuffixes for the full maintenance rules (update list + .env.example + README).
func (c *Config) applyEnvOverrides(prefix string) error {
if err := applyStringEnv(prefix+"SERVER_LISTEN_ADDR", &c.Server.ListenAddr); err != nil {
return err
+84
View File
@@ -56,6 +56,12 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
if cfg.Limits.DefaultTTLDuration != 15*time.Minute {
t.Fatalf("DefaultTTLDuration = %s, want 15m", cfg.Limits.DefaultTTLDuration)
}
if MaxScratchTTL != 24*time.Hour {
t.Fatalf("MaxScratchTTL = %s, want 24h", MaxScratchTTL)
}
if MinScratchTTL != time.Minute {
t.Fatalf("MinScratchTTL = %s, want 1m", MinScratchTTL)
}
if cfg.Limits.RawCacheMaxBytes != 200*1024*1024 {
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(200*1024*1024))
}
@@ -79,6 +85,27 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
}
}
func TestValidateAllowsZeroReadAndWriteTimeout(t *testing.T) {
t.Parallel()
cfg := defaults()
cfg.Storage.DataDir = t.TempDir()
// 0 disables the deadline (net/http semantics). Needed for multi-GiB uploads
// where a fixed 30s body/response timeout cannot work.
cfg.Server.ReadTimeout = "0s"
cfg.Server.WriteTimeout = "0"
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error: %v", err)
}
if cfg.Server.ReadTimeoutDur != 0 {
t.Fatalf("ReadTimeoutDur = %s, want 0", cfg.Server.ReadTimeoutDur)
}
if cfg.Server.WriteTimeoutDur != 0 {
t.Fatalf("WriteTimeoutDur = %s, want 0", cfg.Server.WriteTimeoutDur)
}
}
func TestLoadParsesConfiguredFields(t *testing.T) {
t.Parallel()
@@ -249,6 +276,13 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
},
wantErr: "server.read_timeout",
},
{
name: "negative read timeout",
mutate: func(cfg *Config) {
cfg.Server.ReadTimeout = "-1s"
},
wantErr: "server.read_timeout",
},
{
name: "invalid write timeout",
mutate: func(cfg *Config) {
@@ -256,6 +290,13 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
},
wantErr: "server.write_timeout",
},
{
name: "negative write timeout",
mutate: func(cfg *Config) {
cfg.Server.WriteTimeout = "-1s"
},
wantErr: "server.write_timeout",
},
{
name: "invalid idle timeout",
mutate: func(cfg *Config) {
@@ -713,3 +754,46 @@ func TestGenerateEncryptionKey(t *testing.T) {
t.Fatalf("decoded key length = %d, want 32", len(b1))
}
}
// TestEnvDocumentationIsUpToDate ensures .env.example and the env var
// documentation + example in README.md stay in sync with the env vars
// actually supported by the code (envSuffixes in config.go).
// See the comment on envSuffixes for the list of places that must point back to it.
//
// This is the guard against the recurring "example env and readme have
// become out of sync" problem. When you add a new field that is loaded via
// applyEnvOverrides, you must:
// - add its suffix to envSuffixes
// - add a line (with example value + comment) to .env.example
// - add it to the list and the ```dotenv example in README.md
//
// The test will fail loudly until the docs are updated.
func TestEnvDocumentationIsUpToDate(t *testing.T) {
t.Parallel()
// Check .env.example (at repo root). Note: go test for this package
// runs with cwd set to the package directory (internal/config), so we
// need to go up two levels.
envExample, err := os.ReadFile("../../.env.example")
if err != nil {
t.Fatalf("failed to read .env.example: %v", err)
}
for _, suffix := range envSuffixes {
name := "SCRATCHBOX_" + suffix
if !strings.Contains(string(envExample), name) {
t.Errorf(".env.example is missing %s (add it with a default/example value and a comment matching the style of the others)", name)
}
}
// Check README.md env documentation (the supported list and the example block)
readme, err := os.ReadFile("../../README.md")
if err != nil {
t.Fatalf("failed to read README.md: %v", err)
}
for _, suffix := range envSuffixes {
name := "SCRATCHBOX_" + suffix
if !strings.Contains(string(readme), name) {
t.Errorf("README.md env docs (supported list or example .env block) are missing %s; update both the bullet list and the ```dotenv block", name)
}
}
}
+67 -3
View File
@@ -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)
}
@@ -172,15 +173,22 @@ func (s *Server) scratchPage(w http.ResponseWriter, r *http.Request) {
})
}
// multipartMaxMemory is how much of a multipart upload may live in RAM before
// the remainder spills to temporary files. It must NOT be tied to
// limits.max_upload_size: when that limit is multi-GiB, using it as maxMemory
// forces entire large files into process memory before storage compression.
const multipartMaxMemory = 32 << 20 // 32 MiB
func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxUploadSizeBytes)
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 err := r.ParseMultipartForm(s.cfg.Limits.MaxUploadSizeBytes); err != nil {
if multipart {
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
s.writeCreateError(w, err)
return
}
@@ -222,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)
@@ -315,6 +329,29 @@ func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", disposition)
}
w.Header().Set("Content-Type", meta.ContentType)
// Scratches larger than the raw cache cannot be retained in memory. Stream
// them from storage instead of io.ReadAll so multi-GiB downloads do not OOM.
// Range requests are only supported via the cache path (ServeContent needs a Seeker).
if meta.Size > s.cfg.Limits.RawCacheMaxBytes {
file, _, openErr := s.store.Open(meta.ID)
if openErr != nil {
if errors.Is(openErr, storage.ErrNotFound) {
s.rawCache.Delete(id)
w.WriteHeader(http.StatusGone)
return
}
http.Error(w, "failed to read scratch", http.StatusInternalServerError)
return
}
defer file.Close()
w.Header().Set("Accept-Ranges", "none")
w.Header().Set("Content-Length", fmt.Sprintf("%d", meta.Size))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, file)
return
}
content, err, _ := s.rawCache.GetOrLoad(meta.ID, func() ([]byte, error) {
file, _, openErr := s.store.Open(meta.ID)
if openErr != nil {
@@ -335,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") {
@@ -373,3 +373,49 @@ func min(a, b int) int {
}
return b
}
// Scratches larger than limits.raw_cache_max_size must stream from storage
// (no full in-memory buffer) so multi-GiB downloads stay viable.
func TestRawStreamsWhenLargerThanCache(t *testing.T) {
t.Parallel()
payload := bytes.Repeat([]byte("Z"), 8*1024) // 8 KiB body
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: int64(len(payload) * 2),
defaultTTL: time.Hour,
rateLimitEnable: false,
rawCacheBytes: 1024, // smaller than payload => stream path
})
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewReader(payload))
createReq.Header.Set("Content-Type", "application/octet-stream")
createReq.RemoteAddr = "127.0.0.1:7100"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want 201 body=%q", createRec.Code, createRec.Body.String())
}
var created map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create payload: %v", err)
}
id, _ := created["id"].(string)
if id == "" {
t.Fatal("create payload missing id")
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
rawReq.RemoteAddr = "127.0.0.1:7101"
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusOK {
t.Fatalf("raw status = %d, want 200", rawRec.Code)
}
if got := rawRec.Header().Get("Accept-Ranges"); got != "none" {
t.Fatalf("Accept-Ranges = %q, want none (stream path)", got)
}
if got := rawRec.Body.Bytes(); !bytes.Equal(got, payload) {
t.Fatalf("raw body len=%d, want %d (content mismatch or truncated)", len(got), len(payload))
}
}
@@ -18,6 +18,7 @@ type testServerOptions struct {
maxUploadBytes int64
defaultTTL time.Duration
rateLimitEnable bool
rawCacheBytes int64 // 0 => default 64MiB
}
func newTestHandler(t *testing.T, opts testServerOptions) http.Handler {
@@ -36,6 +37,11 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
t.Fatalf("NewFilesystemStore() error = %v", err)
}
rawCacheBytes := opts.rawCacheBytes
if rawCacheBytes <= 0 {
rawCacheBytes = 64 * 1024 * 1024
}
cfg := config.Config{
Server: config.ServerConfig{
ListenAddr: ":0",
@@ -43,7 +49,7 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
Limits: config.LimitsConfig{
MaxUploadSizeBytes: opts.maxUploadBytes,
DefaultTTLDuration: opts.defaultTTL,
RawCacheMaxBytes: 64 * 1024 * 1024,
RawCacheMaxBytes: rawCacheBytes,
RawCacheMaxEntries: 32,
},
Storage: config.StorageConfig{
@@ -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)
}
}
+68 -1
View File
@@ -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()
+106 -2
View File
@@ -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"}
<section class="panel">
<p class="helper">Size limit: {maxUploadSizeDisplay}</p>
<p class="helper">Expiration: {defaultTTLDisplay}</p>
<p class="helper">Default expiration: {defaultTTLDisplay} (max {maxTTLDisplay})</p>
{#if configError}
<p class="helper warning">Could not load UI config: {configError}</p>
{/if}
@@ -530,6 +619,20 @@
/>
</section>
<section id="ttl-panel" class="input-panel">
<label for="ttl">Expiration</label>
<select
id="ttl"
name="ttl"
bind:value={selectedTTL}
disabled={loading}
>
{#each ttlOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</section>
<button class="link-button nav-button action-button submit-button" type="submit" disabled={loading}>
{#if loading}Creating...{:else}Create scratch{/if}
</button>
@@ -657,7 +760,8 @@
margin-top: 1rem;
}
input[type="file"] {
input[type="file"],
select {
margin-top: 0.5rem;
width: 100%;
background: #121826;