From 99d46ecd2aaff90dccce832be101e686b865c719 Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Sun, 31 May 2026 20:17:49 -0500 Subject: [PATCH] Many changes --- .gitignore | 5 +- AGENTS.md | 5 +- Makefile | 13 +- README.md | 19 +- cmd/scratchbox/generate_config.go | 138 ++- cmd/scratchbox/main_test.go | 67 +- internal/config/config.go | 79 +- internal/config/config_test.go | 41 +- internal/http/handlers.go | 243 ++-- internal/http/handlers_integration_test.go | 712 ++++++++++-- internal/http/handlers_unit_test.go | 146 ++- internal/http/raw_cache.go | 142 +++ internal/http/raw_cache_test.go | 76 ++ internal/storage/filesystem.go | 204 ++-- internal/storage/filesystem_test.go | 386 +++++++ web/assets.go | 21 + web/static/app.css | 54 - web/static/assets/index-Bu5AaCmv.js | 3 + web/static/assets/index-C05A_xhw.css | 1 + web/static/index.html | 13 + web/templates/index.html | 70 -- web/templates/view.html | 26 - web/ui/index.html | 12 + web/ui/package-lock.json | 1173 ++++++++++++++++++++ web/ui/package.json | 13 + web/ui/src/App.svelte | 641 +++++++++++ web/ui/src/main.js | 10 + web/ui/vite.config.js | 10 + 28 files changed, 3729 insertions(+), 594 deletions(-) create mode 100644 internal/http/raw_cache.go create mode 100644 internal/http/raw_cache_test.go create mode 100644 web/assets.go delete mode 100644 web/static/app.css create mode 100644 web/static/assets/index-Bu5AaCmv.js create mode 100644 web/static/assets/index-C05A_xhw.css create mode 100644 web/static/index.html delete mode 100644 web/templates/index.html delete mode 100644 web/templates/view.html create mode 100644 web/ui/index.html create mode 100644 web/ui/package-lock.json create mode 100644 web/ui/package.json create mode 100644 web/ui/src/App.svelte create mode 100644 web/ui/src/main.js create mode 100644 web/ui/vite.config.js diff --git a/.gitignore b/.gitignore index 56bf880..436a03c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ /data/ # Coverage outputs -/*.out \ No newline at end of file +/*.out + +# Frontend dependencies +/web/ui/node_modules/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d9416f8..06cc58c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,11 +24,14 @@ Key product constraints: - `POST /api/scratch` - `GET /api/scratch/{id}` - `GET /s/{id}` -- `GET /raw/{id}` +- `GET /api/raw/{id}` ## Build and test Use `make help` as the source of truth for available build/test targets and descriptions. +Always build and test via make unless it doesn't include targets for that. +Also in the event there is a test without race checking and one with do both. +Otherwise just whats available. To verify your changes you must run tests and do a build. ## Coding conventions diff --git a/Makefile b/Makefile index 272a469..8817d8c 100644 --- a/Makefile +++ b/Makefile @@ -4,15 +4,16 @@ BIN_DIR := ./bin BIN := $(BIN_DIR)/$(APP_NAME) CONFIG ?= config.yaml -.PHONY: help run dev generate-config build test fmt clean +.PHONY: help run dev generate-config build test test-race fmt clean help: @echo "Targets:" @echo " make run - Alias for make dev" @echo " make dev - Build and run server (CONFIG=$(CONFIG))" @echo " make generate-config - Write default config to $(CONFIG)" - @echo " make build - Build binary to $(BIN)" - @echo " make test - Run all Go tests" + @echo " make build - Build Svelte UI and binary to $(BIN)" + @echo " make test - Run all Go tests with timeout and shuffle" + @echo " make test-race - Run all Go tests with race detector and timeout" @echo " make fmt - Format Go code" @echo " make clean - Remove built artifacts" @@ -25,11 +26,15 @@ generate-config: go run $(CMD_DIR) generate-config > $(CONFIG) build: clean + npm --prefix ./web/ui run build mkdir -p $(BIN_DIR) go build -o $(BIN) $(CMD_DIR) test: - go test ./... + go test -timeout 30s -shuffle=on ./... + +test-race: + go test -race -timeout 30s -shuffle=on ./... fmt: go fmt ./... diff --git a/README.md b/README.md index 63e34a5..8cd9688 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ Scratch content is stored on disk gzip-compressed transparently to reduce filesy Without `--config`, built-in defaults are used. +If you modify the web UI source (`web/ui`), rebuild frontend assets with: +- `make build` + ## Configuration All configuration is YAML. @@ -29,11 +32,21 @@ All configuration is YAML. ### `limits` - `max_upload_size`: max request body size for uploads. - - Examples: `5MB`, `100MB`, `1GiB` - - Default: `100MB` + - Supported units: `B`, `KB`, `MB`, `GB`, `KiB`, `MiB`, `GiB` (case-insensitive) + - If no unit is provided, value is interpreted as bytes + - Examples: `5MB`, `100MiB`, `1GiB`, `1048576` + - Default: `100MiB` - `default_ttl`: expiration applied to new scratches. - Must be `> 0` and `<= 24h` - Default: `1h` +- `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` + - Default: `200MiB` +- `raw_cache_max_entries`: max number of decompressed `/api/raw` entries kept in memory. + - Must be `> 0` + - Used together with `raw_cache_max_size` as a secondary cap + - Default: `32` ### `storage` @@ -47,6 +60,8 @@ All configuration is YAML. - `allowed_ips`: optional list of IPs and CIDRs allowed to create scratches. - Applies to write route only: `POST /api/scratch` + - Default includes `127.0.0.1` (localhost-only write access) + - Add trusted IPs/CIDRs for LAN/proxy clients (for example `192.168.1.0/24`) - Empty list means no allowlist restriction - `trust_proxy_headers`: if `true`, client IP extraction honors `X-Forwarded-For` then `X-Real-IP`. - Keep `false` unless behind a trusted reverse proxy that sets these headers. diff --git a/cmd/scratchbox/generate_config.go b/cmd/scratchbox/generate_config.go index 0922513..cb1f1a9 100644 --- a/cmd/scratchbox/generate_config.go +++ b/cmd/scratchbox/generate_config.go @@ -1,7 +1,10 @@ package main import ( + "bytes" "fmt" + "reflect" + "strings" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -22,32 +25,11 @@ func newGenerateConfigCmd() *cobra.Command { } func writeDefaultConfig(cmd *cobra.Command) error { - cfg := config.Config{ - Server: config.ServerConfig{ - ListenAddr: ":8080", - }, - Limits: config.LimitsConfig{ - MaxUploadSize: "100MB", - DefaultTTL: "1h", - }, - Storage: config.StorageConfig{ - DataDir: "./data", - CleanupInterval: "1m", - }, - Security: config.SecurityConfig{ - AllowedIPs: []string{}, - TrustProxyHeaders: false, - RateLimit: config.RateLimitConfig{ - Enabled: true, - RequestsPerMinute: 30, - Burst: 10, - }, - }, - } + cfg := config.Default() - b, err := yaml.Marshal(cfg) + b, err := marshalYAMLWithComments(cfg) if err != nil { - return fmt.Errorf("marshal default config: %w", err) + return fmt.Errorf("marshal default config with comments: %w", err) } if _, err := cmd.OutOrStdout().Write(b); err != nil { @@ -56,3 +38,111 @@ func writeDefaultConfig(cmd *cobra.Command) error { return nil } + +func marshalYAMLWithComments(v any) ([]byte, error) { + root, err := nodeFromValue(reflect.ValueOf(v)) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(4) + if err := enc.Encode(root); err != nil { + return nil, fmt.Errorf("encode yaml: %w", err) + } + if err := enc.Close(); err != nil { + return nil, fmt.Errorf("close yaml encoder: %w", err) + } + return buf.Bytes(), nil +} + +func nodeFromValue(value reflect.Value) (*yaml.Node, error) { + if !value.IsValid() { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil + } + + for value.Kind() == reflect.Ptr { + if value.IsNil() { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil + } + value = value.Elem() + } + + if value.Kind() == reflect.Struct { + return nodeFromStruct(value) + } + + return scalarOrCollectionNode(value.Interface()) +} + +func nodeFromStruct(value reflect.Value) (*yaml.Node, error) { + typ := value.Type() + mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if !field.IsExported() { + continue + } + + key, skip := yamlFieldName(field) + if skip { + continue + } + + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: key, + } + if comment := strings.TrimSpace(field.Tag.Get("comment")); comment != "" { + keyNode.HeadComment = comment + } + + valueNode, err := nodeFromValue(value.Field(i)) + if err != nil { + return nil, fmt.Errorf("field %s: %w", field.Name, err) + } + + mapping.Content = append(mapping.Content, keyNode, valueNode) + } + + return mapping, nil +} + +func scalarOrCollectionNode(value any) (*yaml.Node, error) { + b, err := yaml.Marshal(value) + if err != nil { + return nil, fmt.Errorf("marshal value node: %w", err) + } + + var doc yaml.Node + if err := yaml.Unmarshal(b, &doc); err != nil { + return nil, fmt.Errorf("unmarshal value node: %w", err) + } + if len(doc.Content) == 0 { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil + } + return doc.Content[0], nil +} + +func yamlFieldName(field reflect.StructField) (string, bool) { + tag := strings.TrimSpace(field.Tag.Get("yaml")) + if tag == "-" { + return "", true + } + if tag == "" { + return strings.ToLower(field.Name), false + } + + parts := strings.Split(tag, ",") + key := strings.TrimSpace(parts[0]) + if key == "-" { + return "", true + } + if key == "" { + return strings.ToLower(field.Name), false + } + return key, false +} diff --git a/cmd/scratchbox/main_test.go b/cmd/scratchbox/main_test.go index fe1a573..d5312c6 100644 --- a/cmd/scratchbox/main_test.go +++ b/cmd/scratchbox/main_test.go @@ -16,10 +16,10 @@ import ( "testing" "time" + "gopkg.in/yaml.v3" "scratchbox/internal/config" httpapi "scratchbox/internal/http" "scratchbox/internal/storage" - "gopkg.in/yaml.v3" ) func repoRoot(t *testing.T) string { @@ -125,44 +125,6 @@ security: } } -func TestMainProcessHTTPInitFails(t *testing.T) { - t.Parallel() - - tmp := t.TempDir() - cfgPath := filepath.Join(tmp, "config.yaml") - cfg := ` -server: - listen_addr: "127.0.0.1:0" -limits: - max_upload_size: "1MB" - default_ttl: "1h" -storage: - data_dir: "` + filepath.Join(tmp, "data") + `" - cleanup_interval: "10s" -security: - allowed_ips: [] - trust_proxy_headers: false - rate_limit: - enabled: true - requests_per_minute: 10 - burst: 1 -` - if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess") - cmd.Dir = repoRoot(t) - cmd.Env = append(os.Environ(), - "GO_WANT_MAIN_HELPER=1", - "MAIN_HELPER_MODE=http-fail", - "MAIN_HELPER_CONFIG="+cfgPath, - ) - if err := cmd.Run(); err == nil { - t.Fatalf("expected helper process failure for http init") - } -} - func TestMainHelperProcess(t *testing.T) { if os.Getenv("GO_WANT_MAIN_HELPER") != "1" { return @@ -193,14 +155,6 @@ func TestMainHelperProcess(t *testing.T) { os.Args = []string{"scratchbox", "server", "--config", cfgPath} main() os.Exit(0) - case "http-fail": - cfgPath := os.Getenv("MAIN_HELPER_CONFIG") - if err := os.Chdir(t.TempDir()); err != nil { - os.Exit(2) - } - os.Args = []string{"scratchbox", "server", "--config", cfgPath} - main() - os.Exit(0) default: os.Exit(2) } @@ -222,17 +176,24 @@ func TestGenerateConfigCommandWritesDefaultYAML(t *testing.T) { if out.Len() == 0 { t.Fatalf("generate-config output is empty") } + output := out.String() + if !strings.Contains(output, "# HTTP listener settings.") { + t.Fatalf("generate-config output missing expected section comment") + } + if !strings.Contains(output, "# 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).") { + t.Fatalf("generate-config output missing expected field comment") + } var cfg config.Config - if err := yaml.Unmarshal(out.Bytes(), &cfg); err != nil { + if err := yaml.Unmarshal([]byte(output), &cfg); err != nil { t.Fatalf("yaml.Unmarshal() error = %v", err) } if cfg.Server.ListenAddr != ":8080" { t.Fatalf("server.listen_addr = %q, want %q", cfg.Server.ListenAddr, ":8080") } - if cfg.Limits.MaxUploadSize != "100MB" { - t.Fatalf("limits.max_upload_size = %q, want %q", cfg.Limits.MaxUploadSize, "100MB") + if cfg.Limits.MaxUploadSize != "100MiB" { + t.Fatalf("limits.max_upload_size = %q, want %q", cfg.Limits.MaxUploadSize, "100MiB") } if cfg.Storage.DataDir != "./data" { t.Fatalf("storage.data_dir = %q, want %q", cfg.Storage.DataDir, "./data") @@ -349,7 +310,7 @@ security: t.Fatalf("create payload missing id") } - rawResp, err := client.Get(baseURL + "/raw/" + id) + rawResp, err := client.Get(baseURL + "/api/raw/" + id) if err != nil { t.Fatalf("client.Get(raw) error = %v", err) } @@ -377,7 +338,7 @@ security: if err != nil { t.Fatalf("ReadAll(view) error = %v", err) } - if !strings.Contains(string(viewBody), "Scratch "+id) { - t.Fatalf("view body missing scratch id") + if !strings.Contains(string(viewBody), `id="app"`) { + t.Fatalf("view body missing SPA mount point") } } diff --git a/internal/config/config.go b/internal/config/config.go index 38a8b5f..1f8bb5f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,53 +14,58 @@ import ( ) const ( - defaultListenAddr = ":8080" - defaultMaxUploadSize = "100MB" - defaultTTL = "1h" - defaultDataDir = "./data" - defaultCleanupInterval = "1m" - defaultRateLimitEnabled = true - defaultRequestsPerMinute = 30 - defaultRateLimitBurst = 10 - maxDefaultTTLLimit = 24 * time.Hour - minCleanupInterval = 10 * time.Second + defaultListenAddr = ":8080" + defaultMaxUploadSize = "100MiB" + defaultTTL = "1h" + defaultRawCacheMaxSize = "200MiB" + defaultRawCacheMaxEntries = 32 + defaultDataDir = "./data" + defaultCleanupInterval = "1m" + defaultRateLimitEnabled = true + defaultRequestsPerMinute = 30 + defaultRateLimitBurst = 10 + maxDefaultTTLLimit = 24 * time.Hour + minCleanupInterval = 10 * time.Second ) type Config struct { - Server ServerConfig `yaml:"server"` - Limits LimitsConfig `yaml:"limits"` - Storage StorageConfig `yaml:"storage"` - Security SecurityConfig `yaml:"security"` + Server ServerConfig `yaml:"server" comment:"HTTP listener settings."` + Limits LimitsConfig `yaml:"limits" comment:"Upload, expiration, and raw cache defaults."` + Storage StorageConfig `yaml:"storage" comment:"Filesystem storage location and cleanup cadence."` + Security SecurityConfig `yaml:"security" comment:"Write-route IP restrictions and rate limiting."` } type ServerConfig struct { - ListenAddr string `yaml:"listen_addr"` + ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."` } type LimitsConfig struct { - MaxUploadSize string `yaml:"max_upload_size"` - DefaultTTL string `yaml:"default_ttl"` + 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."` + 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:"-"` DefaultTTLDuration time.Duration `yaml:"-"` + RawCacheMaxBytes int64 `yaml:"-"` } type StorageConfig struct { - DataDir string `yaml:"data_dir"` - CleanupInterval string `yaml:"cleanup_interval"` + DataDir string `yaml:"data_dir" comment:"Directory where scratch files and metadata are stored."` + CleanupInterval string `yaml:"cleanup_interval" comment:"How often expired scratches are deleted (minimum 10s)."` CleanupIntervalParsed time.Duration `yaml:"-"` } type SecurityConfig struct { - AllowedIPs []string `yaml:"allowed_ips"` - TrustProxyHeaders bool `yaml:"trust_proxy_headers"` - RateLimit RateLimitConfig `yaml:"rate_limit"` + AllowedIPs []string `yaml:"allowed_ips" comment:"Allowlist for POST /api/scratch as IPs or CIDRs (examples: 127.0.0.1, 192.168.1.0/24). Keep 127.0.0.1 for local access; add trusted ranges as needed. Empty means unrestricted."` + TrustProxyHeaders bool `yaml:"trust_proxy_headers" comment:"Honor X-Forwarded-For / X-Real-IP when true (trusted proxy only)."` + RateLimit RateLimitConfig `yaml:"rate_limit" comment:"Per-client token bucket on POST /api/scratch."` AllowedPrefixes []netip.Prefix `yaml:"-"` } type RateLimitConfig struct { - Enabled bool `yaml:"enabled"` - RequestsPerMinute int `yaml:"requests_per_minute"` - Burst int `yaml:"burst"` + Enabled bool `yaml:"enabled" comment:"Enable per-client write-route rate limiting."` + RequestsPerMinute int `yaml:"requests_per_minute" comment:"Steady refill rate per client IP."` + Burst int `yaml:"burst" comment:"Maximum immediate burst capacity per client IP."` } func Load(path string) (Config, error) { @@ -81,20 +86,29 @@ func Load(path string) (Config, error) { return cfg, nil } +func Default() Config { + return defaults() +} + func defaults() Config { return Config{ Server: ServerConfig{ ListenAddr: defaultListenAddr, }, Limits: LimitsConfig{ - MaxUploadSize: defaultMaxUploadSize, - DefaultTTL: defaultTTL, + MaxUploadSize: defaultMaxUploadSize, + DefaultTTL: defaultTTL, + RawCacheMaxSize: defaultRawCacheMaxSize, + RawCacheMaxEntries: defaultRawCacheMaxEntries, }, Storage: StorageConfig{ DataDir: defaultDataDir, CleanupInterval: defaultCleanupInterval, }, Security: SecurityConfig{ + AllowedIPs: []string{ + "127.0.0.1", + }, RateLimit: RateLimitConfig{ Enabled: defaultRateLimitEnabled, RequestsPerMinute: defaultRequestsPerMinute, @@ -124,6 +138,15 @@ func (c *Config) Validate() error { } c.Limits.DefaultTTLDuration = ttl + rawCacheBytes, err := parseHumanSize(c.Limits.RawCacheMaxSize) + if err != nil { + return fmt.Errorf("limits.raw_cache_max_size invalid: %w", err) + } + c.Limits.RawCacheMaxBytes = rawCacheBytes + if c.Limits.RawCacheMaxEntries <= 0 { + return errors.New("limits.raw_cache_max_entries must be > 0") + } + cleanupInterval, err := time.ParseDuration(strings.TrimSpace(c.Storage.CleanupInterval)) if err != nil { return fmt.Errorf("storage.cleanup_interval invalid: %w", err) @@ -231,11 +254,9 @@ func parseHumanSize(raw string) (int64, error) { "KB": 1_000, "MB": 1_000_000, "GB": 1_000_000_000, - "TB": 1_000_000_000_000, "KIB": 1024, "MIB": 1024 * 1024, "GIB": 1024 * 1024 * 1024, - "TIB": 1024 * 1024 * 1024 * 1024, } multiplier, ok := multipliers[unitPart] diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 131c5aa..0f6ff3e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -19,17 +19,26 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) { t.Fatalf("Validate() returned error: %v", err) } - if cfg.Limits.MaxUploadSizeBytes != 100_000_000 { - t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(100_000_000)) + if cfg.Limits.MaxUploadSizeBytes != 100*1024*1024 { + t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(100*1024*1024)) } if cfg.Limits.DefaultTTLDuration != time.Hour { t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration) } + if cfg.Limits.RawCacheMaxBytes != 200*1024*1024 { + t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(200*1024*1024)) + } + if cfg.Limits.RawCacheMaxEntries != 32 { + t.Fatalf("RawCacheMaxEntries = %d, want %d", cfg.Limits.RawCacheMaxEntries, 32) + } if cfg.Storage.CleanupIntervalParsed != time.Minute { t.Fatalf("CleanupIntervalParsed = %s, want 1m", cfg.Storage.CleanupIntervalParsed) } - if len(cfg.Security.AllowedPrefixes) != 0 { - t.Fatalf("AllowedPrefixes len = %d, want 0", len(cfg.Security.AllowedPrefixes)) + if len(cfg.Security.AllowedPrefixes) != 1 { + t.Fatalf("AllowedPrefixes len = %d, want 1", len(cfg.Security.AllowedPrefixes)) + } + if got := cfg.Security.AllowedPrefixes[0]; got != netip.MustParsePrefix("127.0.0.1/32") { + t.Fatalf("AllowedPrefixes[0] = %v, want 127.0.0.1/32", got) } } @@ -45,6 +54,8 @@ server: limits: max_upload_size: "2MiB" default_ttl: "2h" + raw_cache_max_size: "16MiB" + raw_cache_max_entries: 5 storage: data_dir: "` + dataDir + `" cleanup_interval: "45s" @@ -76,6 +87,12 @@ security: if cfg.Limits.DefaultTTLDuration != 2*time.Hour { t.Fatalf("DefaultTTLDuration = %s, want 2h", cfg.Limits.DefaultTTLDuration) } + if cfg.Limits.RawCacheMaxBytes != 16*1024*1024 { + t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(16*1024*1024)) + } + if cfg.Limits.RawCacheMaxEntries != 5 { + t.Fatalf("RawCacheMaxEntries = %d, want %d", cfg.Limits.RawCacheMaxEntries, 5) + } if cfg.Storage.CleanupIntervalParsed != 45*time.Second { t.Fatalf("CleanupIntervalParsed = %s, want 45s", cfg.Storage.CleanupIntervalParsed) } @@ -119,6 +136,20 @@ func TestValidateRejectsInvalidValues(t *testing.T) { }, wantErr: "limits.default_ttl", }, + { + name: "invalid raw cache size", + mutate: func(cfg *Config) { + cfg.Limits.RawCacheMaxSize = "oops" + }, + wantErr: "limits.raw_cache_max_size", + }, + { + name: "invalid raw cache max entries", + mutate: func(cfg *Config) { + cfg.Limits.RawCacheMaxEntries = 0 + }, + wantErr: "limits.raw_cache_max_entries", + }, { name: "ttl above max", mutate: func(cfg *Config) { @@ -236,6 +267,8 @@ func TestParseHumanSizeVariants(t *testing.T) { {in: "10", want: 10}, {in: "1.5KB", want: 1500}, {in: "2MiB", want: 2 * 1024 * 1024}, + {in: "1TB", wantErr: "unsupported unit"}, + {in: "1TiB", wantErr: "unsupported unit"}, {in: "0", wantErr: "size must be > 0"}, {in: "abc", wantErr: "does not start with a number"}, {in: "5XB", wantErr: "unsupported unit"}, diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 4794c2d..a6d1437 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -1,64 +1,46 @@ package httpapi import ( + "bytes" "errors" "fmt" - "html/template" "io" + "io/fs" "log" "mime" "net/http" - "path/filepath" "strings" "time" - "unicode/utf8" "scratchbox/internal/config" "scratchbox/internal/storage" + webassets "scratchbox/web" ) -const maxViewPreviewBytes int64 = 2 * 1024 * 1024 - type Server struct { - cfg config.Config - store *storage.FilesystemStore - logger *log.Logger - templates *template.Template -} - -type templateViewData struct { - ID string - Content string - ContentType string - RawURL string - ExpiresAt string - Truncated bool - Binary bool + cfg config.Config + store *storage.FilesystemStore + logger *log.Logger + rawCache *rawContentCache } func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger) (*Server, error) { - tmpl, err := template.ParseFiles( - filepath.Join("web", "templates", "index.html"), - filepath.Join("web", "templates", "view.html"), - ) - if err != nil { - return nil, fmt.Errorf("parse templates: %w", err) - } - return &Server{ - cfg: cfg, - store: store, - logger: logger, - templates: tmpl, + cfg: cfg, + store: store, + logger: logger, + rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes), }, nil } func (s *Server) Routes() http.Handler { mux := http.NewServeMux() - mux.Handle("GET /", http.HandlerFunc(s.index)) + mux.Handle("GET /{$}", http.HandlerFunc(s.serveSPA)) + mux.Handle("GET /u", http.HandlerFunc(s.serveSPA)) + mux.Handle("GET /api/config", http.HandlerFunc(s.getUIConfig)) mux.Handle("GET /api/scratch/{id}", http.HandlerFunc(s.getScratch)) - mux.Handle("GET /s/{id}", http.HandlerFunc(s.viewScratch)) - mux.Handle("GET /raw/{id}", http.HandlerFunc(s.rawScratch)) + mux.Handle("GET /s/{id}", http.HandlerFunc(s.scratchPage)) + mux.Handle("GET /api/raw/{id}", http.HandlerFunc(s.rawScratch)) writeHandler := http.HandlerFunc(s.createScratch) middlewares := make([]func(http.Handler) http.Handler, 0, 2) @@ -69,20 +51,69 @@ func (s *Server) Routes() http.Handler { } mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...)) - mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join("web", "static"))))) + mux.Handle("GET /assets/", http.FileServerFS(webassets.StaticFS())) + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(webassets.StaticFS()))) + mux.Handle("GET /{path...}", http.HandlerFunc(s.notFoundPage)) return mux } -func (s *Server) index(w http.ResponseWriter, r *http.Request) { - if err := s.templates.ExecuteTemplate(w, "index.html", nil); err != nil { - http.Error(w, "failed to render page", http.StatusInternalServerError) +func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) { + s.serveSPAWithStatus(w, http.StatusOK) +} + +func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) { + payload := map[string]any{ + "max_upload_size_bytes": s.cfg.Limits.MaxUploadSizeBytes, + "upload_allowed": s.isUploadAllowedForRequest(r), } + writeJSON(w, http.StatusOK, payload) +} + +func (s *Server) notFoundPage(w http.ResponseWriter, _ *http.Request) { + s.serveSPAWithStatus(w, http.StatusNotFound) +} + +func (s *Server) expiredScratchPage(w http.ResponseWriter) { + s.serveSPAWithStatus(w, http.StatusGone) +} + +func (s *Server) serveSPAWithStatus(w http.ResponseWriter, status int) { + b, err := fs.ReadFile(webassets.StaticFS(), "index.html") + if err != nil { + http.Error(w, "failed to render page", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write(b) +} + +func (s *Server) scratchPage(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + http.NotFound(w, r) + return + } + + meta, err := s.store.Get(id) + if err != nil { + s.expiredScratchPage(w) + return + } + if s.isExpired(meta) { + _ = s.store.Delete(id) + s.expiredScratchPage(w) + return + } + + s.serveSPA(w, r) } 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")) if strings.HasPrefix(contentType, "multipart/form-data") { @@ -102,7 +133,12 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) { http.Error(w, "multipart request must include either file or content, not both", http.StatusBadRequest) return } + if header.Size > s.cfg.Limits.MaxUploadSizeBytes { + http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge) + return + } reader = file + originalName = header.Filename contentType = header.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" @@ -123,10 +159,10 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) { } } - meta, err := s.store.Create(r.Context(), reader, contentType, s.cfg.Limits.DefaultTTLDuration) + meta, err := s.store.CreateWithOriginalName(r.Context(), reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration) if err != nil { if strings.Contains(err.Error(), "request body too large") { - http.Error(w, "upload exceeds limits.max_upload_size", http.StatusRequestEntityTooLarge) + http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge) return } http.Error(w, "failed to save scratch", http.StatusInternalServerError) @@ -139,8 +175,9 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) { "expires_at": meta.ExpiresAt, "size": meta.Size, "content_type": meta.ContentType, + "filename": meta.OriginalName, "view_url": fmt.Sprintf("/s/%s", meta.ID), - "raw_url": fmt.Sprintf("/raw/%s", meta.ID), + "raw_url": fmt.Sprintf("/api/raw/%s", meta.ID), "api_url": fmt.Sprintf("/api/scratch/%s", meta.ID), } writeJSON(w, http.StatusCreated, payload) @@ -166,7 +203,7 @@ func (s *Server) getScratch(w http.ResponseWriter, r *http.Request) { meta, err := s.store.Get(id) if err != nil { - http.NotFound(w, r) + http.Error(w, "scratch gone", http.StatusGone) return } if s.isExpired(meta) { @@ -181,91 +218,73 @@ func (s *Server) getScratch(w http.ResponseWriter, r *http.Request) { "expires_at": meta.ExpiresAt, "size": meta.Size, "content_type": meta.ContentType, + "filename": meta.OriginalName, "view_url": fmt.Sprintf("/s/%s", meta.ID), - "raw_url": fmt.Sprintf("/raw/%s", meta.ID), + "raw_url": fmt.Sprintf("/api/raw/%s", meta.ID), } writeJSON(w, http.StatusOK, payload) } -func (s *Server) viewScratch(w http.ResponseWriter, r *http.Request) { - id := strings.TrimSpace(r.PathValue("id")) - file, meta, err := s.store.Open(id) - if err != nil { - http.NotFound(w, r) - return - } - defer file.Close() - - if s.isExpired(meta) { - _ = s.store.Delete(id) - http.Error(w, "scratch expired", http.StatusGone) - return - } - - limited := io.LimitReader(file, maxViewPreviewBytes+1) - b, err := io.ReadAll(limited) - if err != nil { - http.Error(w, "failed to read scratch", http.StatusInternalServerError) - return - } - - truncated := int64(len(b)) > maxViewPreviewBytes - if truncated { - b = b[:maxViewPreviewBytes] - } - isBinary := !isLikelyText(meta.ContentType, b) - - data := templateViewData{ - ID: meta.ID, - ContentType: meta.ContentType, - RawURL: fmt.Sprintf("/raw/%s", meta.ID), - ExpiresAt: meta.ExpiresAt.Format("2006-01-02 15:04:05 MST"), - Truncated: truncated, - Binary: isBinary, - } - if !isBinary { - data.Content = string(b) - } - - if err := s.templates.ExecuteTemplate(w, "view.html", data); err != nil { - http.Error(w, "failed to render scratch view", http.StatusInternalServerError) - } -} - func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.PathValue("id")) - file, meta, err := s.store.Open(id) + meta, err := s.store.Get(id) if err != nil { - http.NotFound(w, r) + s.rawCache.Delete(id) + w.WriteHeader(http.StatusGone) return } - defer file.Close() if s.isExpired(meta) { _ = s.store.Delete(id) - http.Error(w, "scratch expired", http.StatusGone) + s.rawCache.Delete(id) + w.WriteHeader(http.StatusGone) return } if meta.ContentType == "" { meta.ContentType = "application/octet-stream" } - w.Header().Set("Content-Type", meta.ContentType) - w.Header().Set("Content-Length", fmt.Sprintf("%d", meta.Size)) - if _, err := io.Copy(w, file); err != nil { - s.logger.Printf("failed to stream raw scratch id=%s err=%v", id, err) + if meta.OriginalName != "" { + disposition := fmt.Sprintf(`attachment; filename=%q`, meta.OriginalName) + if formatted := mime.FormatMediaType("attachment", map[string]string{"filename": meta.OriginalName}); formatted != "" { + disposition = formatted + } + w.Header().Set("Content-Disposition", disposition) } + w.Header().Set("Content-Type", meta.ContentType) + content, err, _ := s.rawCache.GetOrLoad(meta.ID, func() ([]byte, error) { + file, _, openErr := s.store.Open(meta.ID) + if openErr != nil { + return nil, openErr + } + defer file.Close() + return io.ReadAll(file) + }) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + s.rawCache.Delete(id) + w.WriteHeader(http.StatusGone) + return + } + http.Error(w, "failed to read scratch", http.StatusInternalServerError) + return + } + http.ServeContent(w, r, meta.ID, meta.CreatedAt, bytes.NewReader(content)) } 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") { - http.Error(w, "upload exceeds limits.max_upload_size", http.StatusRequestEntityTooLarge) + http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge) return } http.Error(w, "invalid upload payload", http.StatusBadRequest) } +func (s *Server) maxUploadSizeError() string { + return fmt.Sprintf("upload exceeds limits.max_upload_size (%d bytes)", s.cfg.Limits.MaxUploadSizeBytes) +} + func (s *Server) isExpired(meta storage.Metadata) bool { return !meta.ExpiresAt.After(nowUTC()) } @@ -274,21 +293,21 @@ func nowUTC() time.Time { return time.Now().UTC() } -func isLikelyText(contentType string, content []byte) bool { - if ct := strings.TrimSpace(contentType); ct != "" { - mediaType, _, err := mime.ParseMediaType(ct) - if err == nil { - if strings.HasPrefix(mediaType, "text/") { - return true - } - switch mediaType { - case "application/json", "application/xml", "application/javascript", "application/x-www-form-urlencoded": - return true - } - } - } - if len(content) == 0 { +func (s *Server) isUploadAllowedForRequest(r *http.Request) bool { + if len(s.cfg.Security.AllowedPrefixes) == 0 { return true } - return utf8.Valid(content) + + clientIP, ok := extractClientIP(r, s.cfg.Security.TrustProxyHeaders) + if !ok { + return false + } + + for _, prefix := range s.cfg.Security.AllowedPrefixes { + if prefix.Contains(clientIP) { + return true + } + } + + return false } diff --git a/internal/http/handlers_integration_test.go b/internal/http/handlers_integration_test.go index 026a830..96e96c2 100644 --- a/internal/http/handlers_integration_test.go +++ b/internal/http/handlers_integration_test.go @@ -3,15 +3,16 @@ package httpapi import ( "bytes" "encoding/json" - "html/template" + "fmt" "io" "log" + "mime" "mime/multipart" "net/http" "net/http/httptest" "path/filepath" - "runtime" "strings" + "sync" "testing" "time" @@ -54,7 +55,7 @@ func TestCreateReadThenExpireScratch(t *testing.T) { t.Fatalf("read metadata status = %d, want %d", metaRec.Code, http.StatusOK) } - rawReq := httptest.NewRequest(http.MethodGet, "/raw/"+id, nil) + rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) rawReq.RemoteAddr = "127.0.0.1:9999" rawRec := httptest.NewRecorder() handler.ServeHTTP(rawRec, rawReq) @@ -76,6 +77,80 @@ func TestCreateReadThenExpireScratch(t *testing.T) { } } +func TestUploadedScratchViewAndRawBeforeAndAfterExpiry(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: 120 * time.Millisecond, + rateLimitEnable: false, + }) + + createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello from upload flow")) + createReq.Header.Set("Content-Type", "text/plain; charset=utf-8") + createReq.RemoteAddr = "127.0.0.1:5090" + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated) + } + + var payload map[string]any + if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode create payload error = %v", err) + } + id, _ := payload["id"].(string) + if id == "" { + t.Fatalf("create payload missing id") + } + + viewReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil) + viewReq.RemoteAddr = "127.0.0.1:5091" + viewRec := httptest.NewRecorder() + handler.ServeHTTP(viewRec, viewReq) + if viewRec.Code != http.StatusOK { + t.Fatalf("view-before-expiry status = %d, want %d", viewRec.Code, http.StatusOK) + } + if !strings.Contains(viewRec.Body.String(), `id="app"`) { + t.Fatalf("expected SPA shell body for /s/{id}, got %q", viewRec.Body.String()) + } + + rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) + rawReq.RemoteAddr = "127.0.0.1:5092" + rawRec := httptest.NewRecorder() + handler.ServeHTTP(rawRec, rawReq) + if rawRec.Code != http.StatusOK { + t.Fatalf("raw-before-expiry status = %d, want %d", rawRec.Code, http.StatusOK) + } + if got := rawRec.Body.String(); got != "hello from upload flow" { + t.Fatalf("raw-before-expiry body = %q, want %q", got, "hello from upload flow") + } + + time.Sleep(180 * time.Millisecond) + + viewExpiredReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil) + viewExpiredReq.RemoteAddr = "127.0.0.1:5093" + viewExpiredRec := httptest.NewRecorder() + handler.ServeHTTP(viewExpiredRec, viewExpiredReq) + if viewExpiredRec.Code != http.StatusGone { + t.Fatalf("view-after-expiry status = %d, want %d", viewExpiredRec.Code, http.StatusGone) + } + if !strings.Contains(viewExpiredRec.Body.String(), `id="app"`) { + t.Fatalf("expected SPA shell body for expired /s/{id}, got %q", viewExpiredRec.Body.String()) + } + + rawExpiredReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) + rawExpiredReq.RemoteAddr = "127.0.0.1:5094" + rawExpiredRec := httptest.NewRecorder() + handler.ServeHTTP(rawExpiredRec, rawExpiredReq) + if rawExpiredRec.Code != http.StatusGone { + t.Fatalf("raw-after-expiry status = %d, want %d", rawExpiredRec.Code, http.StatusGone) + } + if rawExpiredRec.Body.Len() != 0 { + t.Fatalf("expected empty raw body after expiry, got %q", rawExpiredRec.Body.String()) + } +} + func TestCreateScratchRejectsBodyOverLimit(t *testing.T) { t.Parallel() @@ -97,6 +172,48 @@ func TestCreateScratchRejectsBodyOverLimit(t *testing.T) { if !bytes.Contains(rec.Body.Bytes(), []byte("upload exceeds limits.max_upload_size")) { t.Fatalf("unexpected body: %q", rec.Body.String()) } + if !bytes.Contains(rec.Body.Bytes(), []byte("(8 bytes)")) { + t.Fatalf("expected response to include configured max size, got: %q", rec.Body.String()) + } +} + +func TestCreateScratchRejectsMultipartFileOverLimit(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 8, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + file, err := writer.CreateFormFile("file", "too-big.txt") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + if _, err := file.Write([]byte("this is too large")); err != nil { + t.Fatalf("write file error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("multipart close error = %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.RemoteAddr = "127.0.0.1:5060" + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("upload exceeds limits.max_upload_size")) { + t.Fatalf("unexpected body: %q", rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte("(8 bytes)")) { + t.Fatalf("expected response to include configured max size, got: %q", rec.Body.String()) + } } func TestCreateScratchRejectsMultipleMultipartFiles(t *testing.T) { @@ -171,6 +288,70 @@ func TestCreateScratchMultipartContentOnly(t *testing.T) { } } +func TestCreateScratchMultipartPreservesFilenameForRawDownload(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + file, err := writer.CreateFormFile("file", "example upload.txt") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + if _, err := file.Write([]byte("uploaded file body")); err != nil { + t.Fatalf("write file error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("multipart close error = %v", err) + } + + createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", &body) + createReq.Header.Set("Content-Type", writer.FormDataContentType()) + createReq.RemoteAddr = "127.0.0.1:5058" + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d", createRec.Code, http.StatusCreated) + } + + var payload map[string]any + if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode create payload error = %v", err) + } + id, _ := payload["id"].(string) + if id == "" { + t.Fatalf("create payload missing id") + } + + rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) + rawReq.RemoteAddr = "127.0.0.1:5059" + rawRec := httptest.NewRecorder() + handler.ServeHTTP(rawRec, rawReq) + if rawRec.Code != http.StatusOK { + t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusOK) + } + if got := rawRec.Body.String(); got != "uploaded file body" { + t.Fatalf("raw body = %q, want %q", got, "uploaded file body") + } + + disposition := rawRec.Header().Get("Content-Disposition") + if disposition == "" { + t.Fatalf("expected Content-Disposition header") + } + _, params, err := mime.ParseMediaType(disposition) + if err != nil { + t.Fatalf("ParseMediaType() error = %v", err) + } + if got := params["filename"]; got != "example upload.txt" { + t.Fatalf("filename = %q, want %q", got, "example upload.txt") + } +} + func TestCreateScratchMultipartRejectsFileAndContent(t *testing.T) { t.Parallel() @@ -232,7 +413,7 @@ func TestCreateScratchMultipartRequiresFileOrContent(t *testing.T) { } } -func TestGetScratchNotFound(t *testing.T) { +func TestGetScratchNotFoundReturnsGone(t *testing.T) { t.Parallel() handler := newTestHandler(t, testServerOptions{ @@ -245,8 +426,8 @@ func TestGetScratchNotFound(t *testing.T) { req.RemoteAddr = "127.0.0.1:5055" rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) - if rec.Code != http.StatusNotFound { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + if rec.Code != http.StatusGone { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusGone) } } @@ -264,7 +445,7 @@ func TestRawScratchDefaultsContentType(t *testing.T) { t.Fatalf("store.Create() error = %v", err) } - req := httptest.NewRequest(http.MethodGet, "/raw/"+meta.ID, nil) + req := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil) req.RemoteAddr = "127.0.0.1:5056" rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -276,10 +457,93 @@ func TestRawScratchDefaultsContentType(t *testing.T) { } } -func TestViewScratchAndIndexRender(t *testing.T) { +func TestRawScratchSupportsRangeRequests(t *testing.T) { t.Parallel() - handler, store := newTemplateHandlerAndStore(t, testServerOptions{ + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + + createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch")) + createReq.Header.Set("Content-Type", "text/plain; charset=utf-8") + createReq.RemoteAddr = "127.0.0.1:5061" + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated) + } + + var payload map[string]any + if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode create payload error = %v", err) + } + id, _ := payload["id"].(string) + if id == "" { + t.Fatalf("create payload missing id") + } + + rangeReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) + rangeReq.Header.Set("Range", "bytes=0-4") + rangeReq.RemoteAddr = "127.0.0.1:5062" + rangeRec := httptest.NewRecorder() + handler.ServeHTTP(rangeRec, rangeReq) + if rangeRec.Code != http.StatusPartialContent { + t.Fatalf("range status = %d, want %d", rangeRec.Code, http.StatusPartialContent) + } + if got := rangeRec.Body.String(); got != "hello" { + t.Fatalf("range body = %q, want %q", got, "hello") + } + if got := rangeRec.Header().Get("Content-Range"); got != "bytes 0-4/13" { + t.Fatalf("Content-Range = %q, want %q", got, "bytes 0-4/13") + } +} + +func TestRawScratchRejectsUnsatisfiableRange(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + + createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch")) + createReq.Header.Set("Content-Type", "text/plain; charset=utf-8") + createReq.RemoteAddr = "127.0.0.1:5063" + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated) + } + + var payload map[string]any + if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode create payload error = %v", err) + } + id, _ := payload["id"].(string) + if id == "" { + t.Fatalf("create payload missing id") + } + + rangeReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) + rangeReq.Header.Set("Range", "bytes=999-1000") + rangeReq.RemoteAddr = "127.0.0.1:5064" + rangeRec := httptest.NewRecorder() + handler.ServeHTTP(rangeRec, rangeReq) + if rangeRec.Code != http.StatusRequestedRangeNotSatisfiable { + t.Fatalf("range status = %d, want %d", rangeRec.Code, http.StatusRequestedRangeNotSatisfiable) + } + if got := rangeRec.Header().Get("Content-Range"); got != "bytes */13" { + t.Fatalf("Content-Range = %q, want %q", got, "bytes */13") + } +} + +func TestViewUploadAndIndexRender(t *testing.T) { + t.Parallel() + + handler, store := newTestHandlerAndStore(t, testServerOptions{ maxUploadBytes: 10 * 1024 * 1024, defaultTTL: time.Hour, rateLimitEnable: false, @@ -296,6 +560,22 @@ func TestViewScratchAndIndexRender(t *testing.T) { if indexRec.Code != http.StatusOK { t.Fatalf("index status = %d, want %d", indexRec.Code, http.StatusOK) } + if !strings.Contains(indexRec.Body.String(), `id="app"`) { + t.Fatalf("expected index page to include SPA mount point") + } + if !strings.Contains(indexRec.Body.String(), `type="module"`) { + t.Fatalf("expected index page to load frontend bundle") + } + + uploadReq := httptest.NewRequest(http.MethodGet, "/u", nil) + uploadRec := httptest.NewRecorder() + handler.ServeHTTP(uploadRec, uploadReq) + if uploadRec.Code != http.StatusOK { + t.Fatalf("upload status = %d, want %d", uploadRec.Code, http.StatusOK) + } + if !strings.Contains(uploadRec.Body.String(), `id="app"`) { + t.Fatalf("expected upload page to include SPA mount point") + } viewReq := httptest.NewRequest(http.MethodGet, "/s/"+meta.ID, nil) viewRec := httptest.NewRecorder() @@ -303,15 +583,15 @@ func TestViewScratchAndIndexRender(t *testing.T) { if viewRec.Code != http.StatusOK { t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusOK) } - if !strings.Contains(viewRec.Body.String(), "hello view") { - t.Fatalf("expected view body to include scratch content") + if !strings.Contains(viewRec.Body.String(), `id="app"`) { + t.Fatalf("expected scratch route to render SPA shell") } } func TestViewAndRawScratchExpired(t *testing.T) { t.Parallel() - handler, store := newTemplateHandlerAndStore(t, testServerOptions{ + handler, store := newTestHandlerAndStore(t, testServerOptions{ maxUploadBytes: 1024, defaultTTL: time.Hour, rateLimitEnable: false, @@ -328,11 +608,99 @@ func TestViewAndRawScratchExpired(t *testing.T) { t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone) } - rawReq := httptest.NewRequest(http.MethodGet, "/raw/"+meta.ID, nil) + rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil) rawRec := httptest.NewRecorder() handler.ServeHTTP(rawRec, rawReq) - if rawRec.Code != http.StatusNotFound { - t.Fatalf("raw status after lazy delete = %d, want %d", rawRec.Code, http.StatusNotFound) + if rawRec.Code != http.StatusGone { + t.Fatalf("raw status after lazy delete = %d, want %d", rawRec.Code, http.StatusGone) + } +} + +func TestRawScratchExpiredReturnsStatusOnly(t *testing.T) { + t.Parallel() + + handler, store := newTestHandlerAndStore(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + meta, err := store.Create(t.Context(), strings.NewReader("expired body"), "text/plain", -time.Second) + if err != nil { + t.Fatalf("store.Create() error = %v", err) + } + + rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil) + rawRec := httptest.NewRecorder() + handler.ServeHTTP(rawRec, rawReq) + if rawRec.Code != http.StatusGone { + t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusGone) + } + if rawRec.Body.Len() != 0 { + t.Fatalf("expected empty response body, got %q", rawRec.Body.String()) + } +} + +func TestViewAndRawUnknownIDsReturnGone(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + + viewReq := httptest.NewRequest(http.MethodGet, "/s/does-not-exist", nil) + viewRec := httptest.NewRecorder() + handler.ServeHTTP(viewRec, viewReq) + if viewRec.Code != http.StatusGone { + t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone) + } + + rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/does-not-exist", nil) + rawRec := httptest.NewRecorder() + handler.ServeHTTP(rawRec, rawReq) + if rawRec.Code != http.StatusGone { + t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusGone) + } + if rawRec.Body.Len() != 0 { + t.Fatalf("expected empty response body, got %q", rawRec.Body.String()) + } +} + +func TestNeverUploadedScratchIDsAlsoReturnGone(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + + viewReq := httptest.NewRequest(http.MethodGet, "/s/never-uploaded-id", nil) + viewReq.RemoteAddr = "127.0.0.1:5095" + viewRec := httptest.NewRecorder() + handler.ServeHTTP(viewRec, viewReq) + if viewRec.Code != http.StatusGone { + t.Fatalf("view-never-uploaded status = %d, want %d", viewRec.Code, http.StatusGone) + } + + rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/never-uploaded-id", nil) + rawReq.RemoteAddr = "127.0.0.1:5096" + rawRec := httptest.NewRecorder() + handler.ServeHTTP(rawRec, rawReq) + if rawRec.Code != http.StatusGone { + t.Fatalf("raw-never-uploaded status = %d, want %d", rawRec.Code, http.StatusGone) + } + if rawRec.Body.Len() != 0 { + t.Fatalf("expected empty raw body for never-uploaded id, got %q", rawRec.Body.String()) + } + + metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/never-uploaded-id", nil) + metaReq.RemoteAddr = "127.0.0.1:5097" + metaRec := httptest.NewRecorder() + handler.ServeHTTP(metaRec, metaReq) + if metaRec.Code != http.StatusGone { + t.Fatalf("metadata-never-uploaded status = %d, want %d", metaRec.Code, http.StatusGone) } } @@ -355,6 +723,263 @@ func TestCreateScratchRejectsMalformedMultipart(t *testing.T) { } } +func TestConcurrentUploadsRemainConsistentAndReadable(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024 * 1024, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + + const uploads = 80 + + type createdScratch struct { + id string + body string + } + + resultsCh := make(chan createdScratch, uploads) + errCh := make(chan error, uploads) + var wg sync.WaitGroup + + for i := 0; i < uploads; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + + body := fmt.Sprintf("parallel-upload-%03d-%s", i, strings.Repeat("x", i%17)) + createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body)) + createReq.Header.Set("Content-Type", "text/plain; charset=utf-8") + createReq.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 8200+i) + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusCreated { + errCh <- fmt.Errorf("create status = %d, want %d", createRec.Code, http.StatusCreated) + return + } + + var payload map[string]any + if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil { + errCh <- fmt.Errorf("decode create payload error: %w", err) + return + } + id, _ := payload["id"].(string) + if id == "" { + errCh <- fmt.Errorf("create payload missing id") + return + } + + metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil) + metaRec := httptest.NewRecorder() + handler.ServeHTTP(metaRec, metaReq) + if metaRec.Code != http.StatusOK { + errCh <- fmt.Errorf("metadata status = %d, want %d", metaRec.Code, http.StatusOK) + return + } + + rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil) + rawRec := httptest.NewRecorder() + handler.ServeHTTP(rawRec, rawReq) + if rawRec.Code != http.StatusOK { + errCh <- fmt.Errorf("raw status = %d, want %d", rawRec.Code, http.StatusOK) + return + } + if got := rawRec.Body.String(); got != body { + errCh <- fmt.Errorf("raw body mismatch = %q, want %q", got, body) + return + } + + resultsCh <- createdScratch{id: id, body: body} + }(i) + } + + wg.Wait() + close(resultsCh) + close(errCh) + for err := range errCh { + t.Error(err) + } + + seen := make(map[string]struct{}, uploads) + for result := range resultsCh { + if _, exists := seen[result.id]; exists { + t.Errorf("duplicate scratch id generated: %s", result.id) + continue + } + seen[result.id] = struct{}{} + } + if len(seen) != uploads { + t.Fatalf("unique IDs = %d, want %d", len(seen), uploads) + } +} + +func TestParallelClientsCanReadSameScratchAcrossAllReadRoutes(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + + const body = "parallel readers" + createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body)) + createReq.Header.Set("Content-Type", "text/plain; charset=utf-8") + createReq.RemoteAddr = "127.0.0.1:6010" + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated) + } + + var payload map[string]any + if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode create payload error = %v", err) + } + id, _ := payload["id"].(string) + if id == "" { + t.Fatalf("create payload missing id") + } + + paths := []string{ + "/api/raw/" + id, + "/s/" + id, + "/api/scratch/" + id, + } + const requests = 120 + + errCh := make(chan error, requests) + var wg sync.WaitGroup + for i := 0; i < requests; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + path := paths[i%len(paths)] + req := httptest.NewRequest(http.MethodGet, path, nil) + req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 6100+i) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + errCh <- fmt.Errorf("path %s status = %d, want %d", path, rec.Code, http.StatusOK) + return + } + + switch { + case strings.HasPrefix(path, "/api/raw/"): + if got := rec.Body.String(); got != body { + errCh <- fmt.Errorf("raw body = %q, want %q", got, body) + } + case strings.HasPrefix(path, "/s/"): + if !strings.Contains(rec.Body.String(), `id="app"`) { + errCh <- fmt.Errorf("view body missing SPA marker") + } + case strings.HasPrefix(path, "/api/scratch/"): + var gotPayload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &gotPayload); err != nil { + errCh <- fmt.Errorf("metadata decode error: %w", err) + return + } + if gotID, _ := gotPayload["id"].(string); gotID != id { + errCh <- fmt.Errorf("metadata id = %q, want %q", gotID, id) + } + } + }(i) + } + wg.Wait() + close(errCh) + for err := range errCh { + t.Error(err) + } +} + +func TestParallelClientsExpiredScratchReadsReturnGone(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: 80 * time.Millisecond, + rateLimitEnable: false, + }) + + createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("expires soon")) + createReq.Header.Set("Content-Type", "text/plain; charset=utf-8") + createReq.RemoteAddr = "127.0.0.1:7010" + createRec := httptest.NewRecorder() + handler.ServeHTTP(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated) + } + + var payload map[string]any + if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode create payload error = %v", err) + } + id, _ := payload["id"].(string) + if id == "" { + t.Fatalf("create payload missing id") + } + + time.Sleep(130 * time.Millisecond) + + paths := []string{ + "/api/raw/" + id, + "/s/" + id, + "/api/scratch/" + id, + } + const requests = 90 + + errCh := make(chan error, requests) + var wg sync.WaitGroup + for i := 0; i < requests; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + path := paths[i%len(paths)] + req := httptest.NewRequest(http.MethodGet, path, nil) + req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 7100+i) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusGone { + errCh <- fmt.Errorf("expired path %s status = %d, want %d", path, rec.Code, http.StatusGone) + return + } + + if strings.HasPrefix(path, "/s/") && !strings.Contains(rec.Body.String(), `id="app"`) { + errCh <- fmt.Errorf("expired view body missing SPA marker") + } + }(i) + } + wg.Wait() + close(errCh) + for err := range errCh { + t.Error(err) + } +} + +func TestUnknownPathReturnsNotFoundPage(t *testing.T) { + t.Parallel() + + handler := newTestHandler(t, testServerOptions{ + maxUploadBytes: 1024, + defaultTTL: time.Hour, + rateLimitEnable: false, + }) + + req := httptest.NewRequest(http.MethodGet, "/this-path-does-not-exist", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } + if !strings.Contains(rec.Body.String(), `id="app"`) { + t.Fatalf("unexpected not found body: %q", rec.Body.String()) + } +} + type testServerOptions struct { maxUploadBytes int64 defaultTTL time.Duration @@ -384,6 +1009,8 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, Limits: config.LimitsConfig{ MaxUploadSizeBytes: opts.maxUploadBytes, DefaultTTLDuration: opts.defaultTTL, + RawCacheMaxBytes: 64 * 1024 * 1024, + RawCacheMaxEntries: 32, }, Storage: config.StorageConfig{ DataDir: dataDir, @@ -398,57 +1025,10 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, } srv := &Server{ - cfg: cfg, - store: store, - logger: log.New(io.Discard, "", 0), - templates: nil, - } - return srv.Routes(), store -} - -func newTemplateHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, *storage.FilesystemStore) { - t.Helper() - - handler, store := newTestHandlerAndStore(t, opts) - _ = handler // keep shared setup path - - _, filePath, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller failed") - } - repoRoot := filepath.Clean(filepath.Join(filepath.Dir(filePath), "..", "..")) - tmpl, err := template.ParseFiles( - filepath.Join(repoRoot, "web", "templates", "index.html"), - filepath.Join(repoRoot, "web", "templates", "view.html"), - ) - if err != nil { - t.Fatalf("ParseFiles() error = %v", err) - } - - cfg := config.Config{ - Server: config.ServerConfig{ - ListenAddr: ":0", - }, - Limits: config.LimitsConfig{ - MaxUploadSizeBytes: opts.maxUploadBytes, - DefaultTTLDuration: opts.defaultTTL, - }, - Storage: config.StorageConfig{ - DataDir: filepath.Join(t.TempDir(), "unused"), - }, - Security: config.SecurityConfig{ - RateLimit: config.RateLimitConfig{ - Enabled: opts.rateLimitEnable, - RequestsPerMinute: 120, - Burst: 1, - }, - }, - } - srv := &Server{ - cfg: cfg, - store: store, - logger: log.New(io.Discard, "", 0), - templates: tmpl, + cfg: cfg, + store: store, + logger: log.New(io.Discard, "", 0), + rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes), } return srv.Routes(), store } diff --git a/internal/http/handlers_unit_test.go b/internal/http/handlers_unit_test.go index 09b503c..f81ee20 100644 --- a/internal/http/handlers_unit_test.go +++ b/internal/http/handlers_unit_test.go @@ -2,14 +2,13 @@ package httpapi import ( "errors" - "html/template" "io" "log" "net/http" "net/http/httptest" - "os" + "net/netip" "path/filepath" - "runtime" + "strings" "testing" "scratchbox/internal/config" @@ -48,27 +47,7 @@ func TestWriteCreateErrorBranches(t *testing.T) { } } -func TestIsLikelyText(t *testing.T) { - t.Parallel() - - if !isLikelyText("text/plain; charset=utf-8", []byte{0x00}) { - t.Fatalf("text/plain should be treated as text") - } - if !isLikelyText("application/json", []byte{0x00}) { - t.Fatalf("application/json should be treated as text") - } - if isLikelyText("application/octet-stream", []byte{0xff, 0xfe, 0xfd}) { - t.Fatalf("binary octet-stream should not be treated as text") - } - if !isLikelyText("", []byte("hello")) { - t.Fatalf("utf8 payload should be treated as text") - } - if !isLikelyText("", nil) { - t.Fatalf("empty payload should be treated as text") - } -} - -func TestNewServerLoadsTemplates(t *testing.T) { +func TestNewServerInitializes(t *testing.T) { tmp := t.TempDir() store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data")) if err != nil { @@ -81,32 +60,63 @@ func TestNewServerLoadsTemplates(t *testing.T) { }, } - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller failed") - } - repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) - wd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(repoRoot); err != nil { - t.Fatalf("Chdir() error = %v", err) - } - defer func() { - _ = os.Chdir(wd) - }() - srv, err := NewServer(cfg, store, log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("NewServer() error = %v", err) } - if srv == nil || srv.templates == nil { - t.Fatalf("expected initialized server with templates") + if srv == nil { + t.Fatalf("expected initialized server") } } -func TestNewServerTemplateErrorAndIndexError(t *testing.T) { +func TestGetUIConfig(t *testing.T) { + t.Parallel() + + s := &Server{ + cfg: config.Config{ + Limits: config.LimitsConfig{ + MaxUploadSizeBytes: 2048, + }, + }, + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + s.getUIConfig(rec, req) + 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`) { + t.Fatalf("unexpected body: %q", got) + } +} + +func TestGetUIConfigUploadDenied(t *testing.T) { + t.Parallel() + + s := &Server{ + cfg: config.Config{ + Limits: config.LimitsConfig{ + MaxUploadSizeBytes: 2048, + }, + Security: config.SecurityConfig{ + AllowedPrefixes: []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/8"), + }, + }, + }, + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + s.getUIConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if got := rec.Body.String(); got == "" || !containsAll(got, `"upload_allowed":false`) { + t.Fatalf("unexpected body: %q", got) + } +} + +func TestWriteCreateErrorBranchesWithConfig(t *testing.T) { tmp := t.TempDir() store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data")) if err != nil { @@ -118,44 +128,16 @@ func TestNewServerTemplateErrorAndIndexError(t *testing.T) { RateLimit: config.RateLimitConfig{Enabled: false}, }, } - wd, err := os.Getwd() - if err != nil { - t.Fatalf("Getwd() error = %v", err) - } - if err := os.Chdir(tmp); err != nil { - t.Fatalf("Chdir() error = %v", err) - } - defer func() { - _ = os.Chdir(wd) - }() - - if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0)); err == nil { - t.Fatalf("expected NewServer() template parse error") - } - - s := &Server{} - func() { - defer func() { - if recover() == nil { - t.Fatalf("expected panic when templates are nil") - } - }() - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/", nil) - s.index(rec, req) - }() -} - -func TestIndexExecuteTemplateError(t *testing.T) { - t.Parallel() - - s := &Server{ - templates: template.New("empty"), - } - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/", nil) - s.index(rec, req) - if rec.Code != http.StatusInternalServerError { - t.Fatalf("index status = %d, want %d", rec.Code, http.StatusInternalServerError) + if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0)); err != nil { + t.Fatalf("NewServer() error = %v", err) } } + +func containsAll(haystack string, needles ...string) bool { + for _, needle := range needles { + if !strings.Contains(haystack, needle) { + return false + } + } + return true +} diff --git a/internal/http/raw_cache.go b/internal/http/raw_cache.go new file mode 100644 index 0000000..e221bb5 --- /dev/null +++ b/internal/http/raw_cache.go @@ -0,0 +1,142 @@ +package httpapi + +import ( + "container/list" + "sync" +) + +const ( + defaultRawCacheMaxEntries = 32 + defaultRawCacheMaxBytes = 64 * 1024 * 1024 +) + +type rawContentCache struct { + mu sync.Mutex + maxEntries int + maxBytes int64 + totalBytes int64 + items map[string]*list.Element + lru *list.List + inFlight map[string]*rawContentLoad +} + +type rawContentItem struct { + id string + data []byte + size int64 +} + +type rawContentLoad struct { + wait chan struct{} + data []byte + err error +} + +func newRawContentCache(maxEntries int, maxBytes int64) *rawContentCache { + if maxEntries <= 0 { + maxEntries = defaultRawCacheMaxEntries + } + if maxBytes <= 0 { + maxBytes = defaultRawCacheMaxBytes + } + return &rawContentCache{ + maxEntries: maxEntries, + maxBytes: maxBytes, + items: make(map[string]*list.Element, maxEntries), + lru: list.New(), + inFlight: make(map[string]*rawContentLoad), + } +} + +func (c *rawContentCache) GetOrLoad(id string, loader func() ([]byte, error)) ([]byte, error, bool) { + c.mu.Lock() + if elem, ok := c.items[id]; ok { + c.lru.MoveToFront(elem) + item := elem.Value.(*rawContentItem) + data := item.data + c.mu.Unlock() + return data, nil, true + } + if load, ok := c.inFlight[id]; ok { + c.mu.Unlock() + <-load.wait + return load.data, load.err, false + } + + load := &rawContentLoad{wait: make(chan struct{})} + c.inFlight[id] = load + c.mu.Unlock() + + data, err := loader() + load.data = data + load.err = err + + c.mu.Lock() + delete(c.inFlight, id) + if err == nil { + c.setLocked(id, data) + } + c.mu.Unlock() + + close(load.wait) + return data, err, false +} + +func (c *rawContentCache) Delete(id string) { + c.mu.Lock() + defer c.mu.Unlock() + c.deleteLocked(id) +} + +func (c *rawContentCache) setLocked(id string, data []byte) { + size := int64(len(data)) + if size <= 0 || size > c.maxBytes { + c.deleteLocked(id) + return + } + + if elem, ok := c.items[id]; ok { + item := elem.Value.(*rawContentItem) + c.totalBytes -= item.size + item.data = data + item.size = size + c.totalBytes += size + c.lru.MoveToFront(elem) + c.evictLocked() + return + } + + item := &rawContentItem{ + id: id, + data: data, + size: size, + } + elem := c.lru.PushFront(item) + c.items[id] = elem + c.totalBytes += size + c.evictLocked() +} + +func (c *rawContentCache) deleteLocked(id string) { + elem, ok := c.items[id] + if !ok { + return + } + item := elem.Value.(*rawContentItem) + c.totalBytes -= item.size + delete(c.items, id) + c.lru.Remove(elem) +} + +func (c *rawContentCache) evictLocked() { + for c.totalBytes > c.maxBytes || len(c.items) > c.maxEntries { + last := c.lru.Back() + if last == nil { + return + } + item := last.Value.(*rawContentItem) + c.totalBytes -= item.size + delete(c.items, item.id) + c.lru.Remove(last) + } +} diff --git a/internal/http/raw_cache_test.go b/internal/http/raw_cache_test.go new file mode 100644 index 0000000..fad8e78 --- /dev/null +++ b/internal/http/raw_cache_test.go @@ -0,0 +1,76 @@ +package httpapi + +import ( + "bytes" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestRawContentCacheCoalescesConcurrentLoads(t *testing.T) { + t.Parallel() + + cache := newRawContentCache(8, 8*1024*1024) + var loads int32 + want := []byte("coalesced") + + var wg sync.WaitGroup + for i := 0; i < 12; i++ { + wg.Add(1) + go func() { + defer wg.Done() + got, err, _ := cache.GetOrLoad("shared-id", func() ([]byte, error) { + atomic.AddInt32(&loads, 1) + time.Sleep(15 * time.Millisecond) + return want, nil + }) + if err != nil { + t.Errorf("GetOrLoad() error = %v", err) + return + } + if !bytes.Equal(got, want) { + t.Errorf("GetOrLoad() content mismatch = %q, want %q", got, want) + } + }() + } + wg.Wait() + + if got := atomic.LoadInt32(&loads); got != 1 { + t.Fatalf("loader call count = %d, want 1", got) + } +} + +func TestRawContentCacheEvictionCausesReload(t *testing.T) { + t.Parallel() + + cache := newRawContentCache(1, 3) + var loadsA int32 + var loadsB int32 + + if _, err, _ := cache.GetOrLoad("a", func() ([]byte, error) { + atomic.AddInt32(&loadsA, 1) + return []byte("aaa"), nil + }); err != nil { + t.Fatalf("GetOrLoad(a) error = %v", err) + } + if _, err, _ := cache.GetOrLoad("b", func() ([]byte, error) { + atomic.AddInt32(&loadsB, 1) + return []byte("bbb"), nil + }); err != nil { + t.Fatalf("GetOrLoad(b) error = %v", err) + } + if _, err, _ := cache.GetOrLoad("a", func() ([]byte, error) { + atomic.AddInt32(&loadsA, 1) + return []byte("aaa"), nil + }); err != nil { + t.Fatalf("GetOrLoad(a second) error = %v", err) + } + + if got := atomic.LoadInt32(&loadsB); got != 1 { + t.Fatalf("loadsB = %d, want 1", got) + } + if got := atomic.LoadInt32(&loadsA); got != 2 { + t.Fatalf("loadsA = %d, want 2", got) + } +} diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index c86ff82..a010a4b 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -11,34 +11,39 @@ import ( "io" "os" "path/filepath" + "strings" "sync" "time" ) const ( - indexFilename = "metadata.json" - filesDirName = "scratches" - idBytes = 12 + indexFilename = "metadata.json" + filesDirName = "scratches" + idBytes = 12 + maxCreateIDAttempts = 100 ) var ErrNotFound = errors.New("scratch not found") type Metadata struct { - ID string `json:"id"` - FilePath string `json:"file_path"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt time.Time `json:"expires_at"` - ContentType string `json:"content_type"` - Size int64 `json:"size"` + ID string `json:"id"` + FilePath string `json:"file_path"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + ContentType string `json:"content_type"` + OriginalName string `json:"original_name,omitempty"` + Size int64 `json:"size"` } type FilesystemStore struct { - dataDir string - filesDir string - index string + dataDir string + filesDir string + index string + idGenerator func() (string, error) - mu sync.RWMutex - metadata map[string]Metadata + mu sync.RWMutex + metadata map[string]Metadata + reservedIDs map[string]struct{} } func NewFilesystemStore(dataDir string) (*FilesystemStore, error) { @@ -48,10 +53,12 @@ func NewFilesystemStore(dataDir string) (*FilesystemStore, error) { } store := &FilesystemStore{ - dataDir: dataDir, - filesDir: filesDir, - index: filepath.Join(dataDir, indexFilename), - metadata: map[string]Metadata{}, + dataDir: dataDir, + filesDir: filesDir, + index: filepath.Join(dataDir, indexFilename), + idGenerator: generateID, + metadata: map[string]Metadata{}, + reservedIDs: map[string]struct{}{}, } if err := store.loadIndex(); err != nil { return nil, err @@ -63,59 +70,124 @@ func NewFilesystemStore(dataDir string) (*FilesystemStore, error) { } func (s *FilesystemStore) Create(ctx context.Context, body io.Reader, contentType string, ttl time.Duration) (Metadata, error) { - id, err := generateID() - if err != nil { - return Metadata{}, fmt.Errorf("generate id: %w", err) - } + return s.CreateWithOriginalName(ctx, body, contentType, "", ttl) +} - tempFile, err := os.CreateTemp(s.filesDir, id+".tmp-*") - if err != nil { - return Metadata{}, fmt.Errorf("create temp file: %w", err) - } +func (s *FilesystemStore) CreateWithOriginalName(ctx context.Context, body io.Reader, contentType string, originalName string, ttl time.Duration) (Metadata, error) { + for attempt := 0; attempt < maxCreateIDAttempts; attempt++ { + id, err := s.idGenerator() + if err != nil { + return Metadata{}, fmt.Errorf("generate id: %w", err) + } + finalPath := filepath.Join(s.filesDir, id) - gzipWriter := gzip.NewWriter(tempFile) - size, copyErr := io.Copy(gzipWriter, body) - gzipCloseErr := gzipWriter.Close() - closeErr := tempFile.Close() - if copyErr != nil { - _ = os.Remove(tempFile.Name()) - return Metadata{}, fmt.Errorf("write scratch content: %w", copyErr) - } - if gzipCloseErr != nil { - _ = os.Remove(tempFile.Name()) - return Metadata{}, fmt.Errorf("close gzip writer: %w", gzipCloseErr) - } - if closeErr != nil { - _ = os.Remove(tempFile.Name()) - return Metadata{}, fmt.Errorf("close temp file: %w", closeErr) - } + s.mu.RLock() + _, exists := s.metadata[id] + _, reserved := s.reservedIDs[id] + s.mu.RUnlock() + if exists || reserved { + continue + } + if _, err := os.Stat(finalPath); err == nil { + continue + } else if !errors.Is(err, os.ErrNotExist) { + return Metadata{}, fmt.Errorf("stat candidate scratch path: %w", err) + } - finalPath := filepath.Join(s.filesDir, id) - if err := os.Rename(tempFile.Name(), finalPath); err != nil { - _ = os.Remove(tempFile.Name()) - return Metadata{}, fmt.Errorf("atomically move scratch file: %w", err) - } - - now := time.Now().UTC() - meta := Metadata{ - ID: id, - FilePath: finalPath, - CreatedAt: now, - ExpiresAt: now.Add(ttl), - ContentType: contentType, - Size: size, - } - - s.mu.Lock() - s.metadata[id] = meta - if err := s.persistLocked(); err != nil { - delete(s.metadata, id) + s.mu.Lock() + if _, exists := s.metadata[id]; exists { + s.mu.Unlock() + continue + } + if _, reserved := s.reservedIDs[id]; reserved { + s.mu.Unlock() + continue + } + // Reserve the ID before consuming the request body to avoid + // concurrent writers choosing the same ID. + s.reservedIDs[id] = struct{}{} s.mu.Unlock() - _ = os.Remove(finalPath) - return Metadata{}, err + + tempFile, err := os.CreateTemp(s.filesDir, id+".tmp-*") + if err != nil { + s.mu.Lock() + delete(s.reservedIDs, id) + s.mu.Unlock() + return Metadata{}, fmt.Errorf("create temp file: %w", err) + } + + gzipWriter := gzip.NewWriter(tempFile) + size, copyErr := io.Copy(gzipWriter, body) + gzipCloseErr := gzipWriter.Close() + closeErr := tempFile.Close() + if copyErr != nil { + _ = os.Remove(tempFile.Name()) + s.mu.Lock() + delete(s.reservedIDs, id) + s.mu.Unlock() + return Metadata{}, fmt.Errorf("write scratch content: %w", copyErr) + } + if gzipCloseErr != nil { + _ = os.Remove(tempFile.Name()) + s.mu.Lock() + delete(s.reservedIDs, id) + s.mu.Unlock() + return Metadata{}, fmt.Errorf("close gzip writer: %w", gzipCloseErr) + } + if closeErr != nil { + _ = os.Remove(tempFile.Name()) + s.mu.Lock() + delete(s.reservedIDs, id) + s.mu.Unlock() + return Metadata{}, fmt.Errorf("close temp file: %w", closeErr) + } + + if err := os.Rename(tempFile.Name(), finalPath); err != nil { + _ = os.Remove(tempFile.Name()) + s.mu.Lock() + delete(s.reservedIDs, id) + s.mu.Unlock() + return Metadata{}, fmt.Errorf("atomically move scratch file: %w", err) + } + + now := time.Now().UTC() + meta := Metadata{ + ID: id, + FilePath: finalPath, + CreatedAt: now, + ExpiresAt: now.Add(ttl), + ContentType: contentType, + OriginalName: normalizeOriginalName(originalName), + Size: size, + } + + s.mu.Lock() + delete(s.reservedIDs, id) + s.metadata[id] = meta + if err := s.persistLocked(); err != nil { + delete(s.metadata, id) + s.mu.Unlock() + _ = os.Remove(finalPath) + return Metadata{}, err + } + s.mu.Unlock() + return meta, nil } - s.mu.Unlock() - return meta, nil + + return Metadata{}, fmt.Errorf("failed to allocate unique scratch id after %d attempts", maxCreateIDAttempts) +} + +func normalizeOriginalName(originalName string) string { + name := strings.TrimSpace(originalName) + if name == "" { + return "" + } + name = filepath.Base(name) + name = strings.TrimSpace(name) + if name == "." || name == string(filepath.Separator) { + return "" + } + return name } func (s *FilesystemStore) Get(id string) (Metadata, error) { diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go index b371e2a..7d5e543 100644 --- a/internal/storage/filesystem_test.go +++ b/internal/storage/filesystem_test.go @@ -5,9 +5,12 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "os" "path/filepath" + "strings" + "sync" "testing" "time" ) @@ -51,6 +54,226 @@ func TestCreateStoresGzipAndOpenIsTransparent(t *testing.T) { } } +func TestCreateWithOriginalNameNormalizesFilename(t *testing.T) { + t.Parallel() + + store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + + meta, err := store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", " ../../demo.txt ", time.Hour) + if err != nil { + t.Fatalf("CreateWithOriginalName() error = %v", err) + } + if meta.OriginalName != "demo.txt" { + t.Fatalf("meta.OriginalName = %q, want %q", meta.OriginalName, "demo.txt") + } + + reloaded, err := NewFilesystemStore(store.dataDir) + if err != nil { + t.Fatalf("NewFilesystemStore() reload error = %v", err) + } + got, err := reloaded.Get(meta.ID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if got.OriginalName != "demo.txt" { + t.Fatalf("persisted OriginalName = %q, want %q", got.OriginalName, "demo.txt") + } +} + +func TestCreateWithOriginalNameRetriesOnIDCollision(t *testing.T) { + t.Parallel() + + store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + + existingID := "aaaaaaaaaaaaaaaaaaaaaaaa" + existingPath := filepath.Join(store.filesDir, existingID) + if err := os.WriteFile(existingPath, []byte{0x1f, 0x8b, 0x08, 0x00}, 0o644); err != nil { + t.Fatalf("WriteFile(existing collision path) error = %v", err) + } + store.mu.Lock() + store.metadata[existingID] = Metadata{ + ID: existingID, + FilePath: existingPath, + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + ContentType: "text/plain", + Size: 4, + } + store.mu.Unlock() + + nextID := "bbbbbbbbbbbbbbbbbbbbbbbb" + calls := 0 + store.idGenerator = func() (string, error) { + calls++ + if calls == 1 { + return existingID, nil + } + return nextID, nil + } + + meta, err := store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", "hello.txt", time.Hour) + if err != nil { + t.Fatalf("CreateWithOriginalName() error = %v", err) + } + if meta.ID != nextID { + t.Fatalf("meta.ID = %q, want %q", meta.ID, nextID) + } + if calls != 2 { + t.Fatalf("idGenerator calls = %d, want 2", calls) + } +} + +func TestCreateWithOriginalNameFailsAfterMaxIDRetries(t *testing.T) { + t.Parallel() + + store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + + existingID := "cccccccccccccccccccccccc" + existingPath := filepath.Join(store.filesDir, existingID) + store.mu.Lock() + store.metadata[existingID] = Metadata{ + ID: existingID, + FilePath: existingPath, + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + ContentType: "text/plain", + } + store.mu.Unlock() + + store.idGenerator = func() (string, error) { + return existingID, nil + } + + _, err = store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", "hello.txt", time.Hour) + if err == nil { + t.Fatalf("CreateWithOriginalName() error = nil, want retry exhaustion error") + } + if !strings.Contains(err.Error(), "failed to allocate unique scratch id") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateWithOriginalNameRetriesWhenIDIsReservedInFlight(t *testing.T) { + t.Parallel() + + store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + + const collideID = "abababababababababababab" + const uniqueID = "cdcdcdcdcdcdcdcdcdcdcdcd" + + var idMu sync.Mutex + idCalls := 0 + store.idGenerator = func() (string, error) { + idMu.Lock() + defer idMu.Unlock() + idCalls++ + if idCalls <= 2 { + return collideID, nil + } + return uniqueID, nil + } + + release := make(chan struct{}) + firstMetaCh := make(chan Metadata, 1) + firstErrCh := make(chan error, 1) + go func() { + meta, createErr := store.CreateWithOriginalName( + context.Background(), + &blockingReader{release: release, payload: []byte("first upload")}, + "text/plain", + "first.txt", + time.Hour, + ) + firstMetaCh <- meta + firstErrCh <- createErr + }() + + deadline := time.Now().Add(2 * time.Second) + for { + store.mu.RLock() + _, reserved := store.reservedIDs[collideID] + store.mu.RUnlock() + if reserved { + break + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for first upload reservation") + } + time.Sleep(2 * time.Millisecond) + } + + secondMeta, err := store.CreateWithOriginalName( + context.Background(), + strings.NewReader("second upload"), + "text/plain", + "second.txt", + time.Hour, + ) + if err != nil { + t.Fatalf("second CreateWithOriginalName() error = %v", err) + } + if secondMeta.ID != uniqueID { + t.Fatalf("second meta.ID = %q, want %q after collision retry", secondMeta.ID, uniqueID) + } + if secondMeta.ID == collideID { + t.Fatalf("second create reused in-flight reserved ID") + } + + close(release) + firstMeta := <-firstMetaCh + if err := <-firstErrCh; err != nil { + t.Fatalf("first CreateWithOriginalName() error = %v", err) + } + if firstMeta.ID != collideID { + t.Fatalf("first meta.ID = %q, want %q", firstMeta.ID, collideID) + } + + firstReader, _, err := store.Open(firstMeta.ID) + if err != nil { + t.Fatalf("Open(first) error = %v", err) + } + defer firstReader.Close() + firstBody, err := io.ReadAll(firstReader) + if err != nil { + t.Fatalf("ReadAll(first) error = %v", err) + } + if string(firstBody) != "first upload" { + t.Fatalf("first content = %q, want %q", string(firstBody), "first upload") + } + + secondReader, _, err := store.Open(secondMeta.ID) + if err != nil { + t.Fatalf("Open(second) error = %v", err) + } + defer secondReader.Close() + secondBody, err := io.ReadAll(secondReader) + if err != nil { + t.Fatalf("ReadAll(second) error = %v", err) + } + if string(secondBody) != "second upload" { + t.Fatalf("second content = %q, want %q", string(secondBody), "second upload") + } + + idMu.Lock() + totalCalls := idCalls + idMu.Unlock() + if totalCalls < 3 { + t.Fatalf("idGenerator call count = %d, want at least 3", totalCalls) + } +} + func TestGetDeleteAndNotFound(t *testing.T) { t.Parallel() @@ -400,3 +623,166 @@ func TestNewFilesystemStoreFailsWhenDataDirIsFile(t *testing.T) { t.Fatalf("NewFilesystemStore() error = nil, want mkdir failure") } } + +type blockingReader struct { + release <-chan struct{} + payload []byte + sent bool +} + +func (r *blockingReader) Read(p []byte) (int, error) { + if r.sent { + return 0, io.EOF + } + <-r.release + r.sent = true + return copy(p, r.payload), nil +} + +func TestCreateReservationIsNotVisibleToCleanupOrReads(t *testing.T) { + t.Parallel() + + store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + + const reservedID = "ffffffffffffffffffffffff" + store.idGenerator = func() (string, error) { + return reservedID, nil + } + + release := make(chan struct{}) + createErrCh := make(chan error, 1) + go func() { + _, createErr := store.CreateWithOriginalName( + context.Background(), + &blockingReader{release: release, payload: []byte("in-flight")}, + "text/plain", + "", + time.Hour, + ) + createErrCh <- createErr + }() + + deadline := time.Now().Add(2 * time.Second) + for { + store.mu.RLock() + _, reserved := store.reservedIDs[reservedID] + _, visible := store.metadata[reservedID] + store.mu.RUnlock() + if reserved { + if visible { + t.Fatalf("in-flight ID should not appear in metadata") + } + break + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for ID reservation") + } + time.Sleep(2 * time.Millisecond) + } + + if _, err := store.Get(reservedID); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get() during in-flight create error = %v, want ErrNotFound", err) + } + + deleted, err := store.DeleteExpired(time.Now().UTC()) + if err != nil { + t.Fatalf("DeleteExpired() error = %v", err) + } + if deleted != 0 { + t.Fatalf("DeleteExpired() = %d, want 0 for in-flight create", deleted) + } + + close(release) + if err := <-createErrCh; err != nil { + t.Fatalf("CreateWithOriginalName() error = %v", err) + } + + reader, _, err := store.Open(reservedID) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer reader.Close() + + body, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("ReadAll() error = %v", err) + } + if string(body) != "in-flight" { + t.Fatalf("Open() content = %q, want %q", string(body), "in-flight") + } +} + +func TestConcurrentCreateWithOriginalNameProducesUniqueReadableEntries(t *testing.T) { + t.Parallel() + + store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data")) + if err != nil { + t.Fatalf("NewFilesystemStore() error = %v", err) + } + + const writers = 64 + errCh := make(chan error, writers) + idsCh := make(chan string, writers) + var wg sync.WaitGroup + + for i := 0; i < writers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + + payload := fmt.Sprintf("storage-writer-%03d-%s", i, strings.Repeat("z", i%13)) + meta, createErr := store.CreateWithOriginalName( + context.Background(), + strings.NewReader(payload), + "text/plain; charset=utf-8", + fmt.Sprintf("w-%03d.txt", i), + time.Hour, + ) + if createErr != nil { + errCh <- fmt.Errorf("CreateWithOriginalName() error: %w", createErr) + return + } + + reader, _, openErr := store.Open(meta.ID) + if openErr != nil { + errCh <- fmt.Errorf("Open() error: %w", openErr) + return + } + defer reader.Close() + + got, readErr := io.ReadAll(reader) + if readErr != nil { + errCh <- fmt.Errorf("ReadAll() error: %w", readErr) + return + } + if string(got) != payload { + errCh <- fmt.Errorf("content mismatch = %q, want %q", string(got), payload) + return + } + + idsCh <- meta.ID + }(i) + } + + wg.Wait() + close(errCh) + close(idsCh) + for err := range errCh { + t.Error(err) + } + + seen := make(map[string]struct{}, writers) + for id := range idsCh { + if _, exists := seen[id]; exists { + t.Errorf("duplicate id detected: %s", id) + continue + } + seen[id] = struct{}{} + } + if len(seen) != writers { + t.Fatalf("unique IDs = %d, want %d", len(seen), writers) + } +} diff --git a/web/assets.go b/web/assets.go new file mode 100644 index 0000000..77d2258 --- /dev/null +++ b/web/assets.go @@ -0,0 +1,21 @@ +package webassets + +import ( + "embed" + "io/fs" +) + +//go:embed static +var embeddedFiles embed.FS + +func StaticFS() fs.FS { + return mustSub("static") +} + +func mustSub(dir string) fs.FS { + sub, err := fs.Sub(embeddedFiles, dir) + if err != nil { + panic(err) + } + return sub +} diff --git a/web/static/app.css b/web/static/app.css deleted file mode 100644 index 15cea7d..0000000 --- a/web/static/app.css +++ /dev/null @@ -1,54 +0,0 @@ -body { - margin: 0; - font-family: Inter, system-ui, sans-serif; - background: #10141a; - color: #e5e7eb; -} - -.container { - max-width: 900px; - margin: 2rem auto; - padding: 0 1rem 2rem; -} - -textarea { - width: 100%; - box-sizing: border-box; - border: 1px solid #334155; - border-radius: 6px; - background: #0f172a; - color: #e2e8f0; - padding: 0.75rem; - margin-bottom: 1rem; -} - -button { - background: #3b82f6; - border: 0; - color: white; - border-radius: 6px; - padding: 0.6rem 1rem; - cursor: pointer; -} - -.error { - margin-top: 1rem; - color: #fecaca; -} - -.hidden { - display: none; -} - -pre { - background: #0b1220; - border: 1px solid #334155; - border-radius: 6px; - padding: 1rem; - overflow: auto; - white-space: pre-wrap; -} - -a { - color: #93c5fd; -} diff --git a/web/static/assets/index-Bu5AaCmv.js b/web/static/assets/index-Bu5AaCmv.js new file mode 100644 index 0000000..c2266fd --- /dev/null +++ b/web/static/assets/index-Bu5AaCmv.js @@ -0,0 +1,3 @@ +(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=Array.isArray,t=Array.prototype.indexOf,n=Array.prototype.includes,r=Array.from,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyDescriptors,s=Object.prototype,c=Array.prototype,l=Object.getPrototypeOf,u=Object.isExtensible,d=()=>{};function f(e){for(var t=0;t{e=n,t=r}),resolve:e,reject:t}}var m=1024,h=2048,g=4096,_=8192,ee=16384,te=32768,ne=1<<25,re=65536,ie=1<<19,ae=1<<20,oe=65536,se=1<<21,ce=1<<22,le=1<<23,ue=Symbol(`$state`),de=Symbol(``),fe=Symbol(`attributes`),pe=Symbol(`class`),me=Symbol(`style`),he=Symbol(`text`),ge=Symbol(`form reset`),_e=new class extends Error{name=`StaleReactionError`;message="The reaction that called `getAbortSignal()` was re-run or destroyed"},ve=!!globalThis.document?.contentType&&globalThis.document.contentType.includes(`xml`);function ye(e){throw Error(`https://svelte.dev/e/lifecycle_outside_component`)}function be(){throw Error(`https://svelte.dev/e/async_derived_orphan`)}function xe(e){throw Error(`https://svelte.dev/e/effect_in_teardown`)}function Se(){throw Error(`https://svelte.dev/e/effect_in_unowned_derived`)}function Ce(e){throw Error(`https://svelte.dev/e/effect_orphan`)}function we(){throw Error(`https://svelte.dev/e/effect_update_depth_exceeded`)}function Te(){throw Error(`https://svelte.dev/e/state_descriptors_fixed`)}function Ee(){throw Error(`https://svelte.dev/e/state_prototype_fixed`)}function De(){throw Error(`https://svelte.dev/e/state_unsafe_mutation`)}function Oe(){throw Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`)}var ke={},v=Symbol(`uninitialized`),Ae=`http://www.w3.org/1999/xhtml`;function je(){console.warn(`https://svelte.dev/e/derived_inert`)}function Me(e){console.warn(`https://svelte.dev/e/hydration_mismatch`)}function Ne(){console.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`)}var y=!1;function Pe(e){y=e}var b;function x(e){if(e===null)throw Me(),ke;return b=e}function Fe(){return x(N(b))}function S(e){if(y){if(N(b)!==null)throw Me(),ke;b=e}}function Ie(e=1){if(y){for(var t=e,n=b;t--;)n=N(n);b=n}}function Le(e=!0){for(var t=0,n=b;;){if(n.nodeType===8){var r=n.data;if(r===`]`){if(t===0)return n;--t}else (r===`[`||r===`[!`||r[0]===`[`&&!isNaN(Number(r.slice(1))))&&(t+=1)}var i=N(n);e&&n.remove(),n=i}}function Re(e){if(!e||e.nodeType!==8)throw Me(),ke;return e.data}function ze(e){return e===this.v}function Be(e,t){return e==e?e!==t||typeof e==`object`&&!!e||typeof e==`function`:t==t}function Ve(e){return!Be(e,this.v)}var C=!1,He=!1,w=null;function Ue(e){w=e}function We(e,t=!1,n){w={p:w,i:!1,c:null,e:null,s:e,x:null,r:W,l:He&&!t?{s:null,u:null,$:[]}:null}}function Ge(e){var t=w,n=t.e;if(n!==null){t.e=null;for(var r of n)_n(r)}return e!==void 0&&(t.x=e),t.i=!0,w=t.p,e??{}}function Ke(){return!He||w!==null&&w.l===null}var qe=[];function Je(){var e=qe;qe=[],f(e)}function Ye(e){if(qe.length===0&&!st){var t=qe;queueMicrotask(()=>{t===qe&&Je()})}qe.push(e)}function Xe(){for(;qe.length>0;)Je()}function Ze(e){var t=W;if(t===null)return V.f|=le,e;if(!(t.f&32768)&&!(t.f&4))throw e;T(e,t)}function T(e,t){for(;t!==null;){if(t.f&128){if(!(t.f&32768))throw e;try{t.b.error(e);return}catch(t){e=t}}t=t.parent}throw e}var Qe=~(h|g|m);function E(e,t){e.f=e.f&Qe|t}function $e(e){e.f&512||e.deps===null?E(e,m):E(e,g)}function et(e){if(e!==null)for(let t of e)!(t.f&2)||!(t.f&65536)||(t.f^=oe,et(t.deps))}function tt(e,t,n){e.f&2048?t.add(e):e.f&4096&&n.add(e),et(e.deps),E(e,m)}var nt=!1,rt=null,it=null,D=null,at=null,O=null,ot=null,st=!1,ct=!1,lt=null,ut=null,dt=0,ft=1,pt=class e{id=ft++;#e=!1;linked=!0;#t=null;#n=null;async_deriveds=new Map;current=new Map;previous=new Map;#r=new Set;#i=new Set;#a=0;#o=new Map;#s=null;#c=[];#l=[];#u=new Set;#d=new Set;#f=new Map;#p=new Set;is_fork=!1;#m=!1;constructor(){it===null?rt=it=this:(it.#n=this,this.#t=it),it=this}#h(){if(this.is_fork)return!0;for(let n of this.#o.keys()){for(var e=n,t=!1;e.parent!==null;){if(this.#f.has(e)){t=!0;break}e=e.parent}if(!t)return!0}return!1}skip_effect(e){this.#f.has(e)||this.#f.set(e,{d:[],m:[]}),this.#p.delete(e)}unskip_effect(e,t=e=>this.schedule(e)){var n=this.#f.get(e);if(n){this.#f.delete(e);for(var r of n.d)E(r,h),t(r);for(r of n.m)E(r,g),t(r)}this.#p.add(e)}#g(){this.#e=!0,dt++>1e3&&(this.#S(),ht());for(let e of this.#u)this.#d.delete(e),E(e,h),this.schedule(e);for(let e of this.#d)E(e,g),this.schedule(e);let t=this.#c;this.#c=[],this.apply();var n=lt=[],r=[],i=ut=[];for(let e of t)try{this.#_(e,n,r)}catch(t){throw xt(e),this.#h()||this.discard(),t}if(D=null,i.length>0){var a=e.ensure();for(let e of i)a.schedule(e)}if(lt=null,ut=null,this.#h()){this.#b(r),this.#b(n);for(let[e,t]of this.#f)bt(e,t);i.length>0&&D.#g();return}let o=this.#v();if(o){this.#b(r),this.#b(n),o.#y(this);return}this.#u.clear(),this.#d.clear();for(let e of this.#r)e(this);this.#r.clear(),at=this,gt(r),gt(n),at=null,this.#s?.resolve();var s=D;if(this.#a===0&&(this.#c.length===0||s!==null)&&(this.#S(),C&&(this.#x(),D=s)),this.#c.length>0)if(s!==null){let e=s;e.#c.push(...this.#c.filter(t=>!e.#c.includes(t)))}else s=this;s!==null&&s.#g()}#_(e,t,n){e.f^=m;for(var r=e.first;r!==null;){var i=r.f,a=(i&96)!=0;if(!(a&&i&1024||i&8192||this.#f.has(r))&&r.fn!==null){a?r.f^=m:i&4?t.push(r):C&&i&16777224?n.push(r):Hn(r)&&(i&16&&this.#d.add(r),qn(r));var o=r.first;if(o!==null){r=o;continue}}for(;r!==null;){var s=r.next;if(s!==null){r=s;break}r=r.parent}}}#v(){for(var e=this.#t;e!==null;){if(!e.is_fork){for(let[t,[,n]]of this.current)if(e.current.has(t)&&!n)return e}e=e.#t}return null}#y(e){for(let[t,n]of e.current)!this.previous.has(t)&&e.previous.has(t)&&this.previous.set(t,e.previous.get(t)),this.current.set(t,n);for(let[t,n]of e.async_deriveds){let e=this.async_deriveds.get(t);e&&n.promise.then(e.resolve).catch(e.reject)}this.transfer_effects(e.#u,e.#d);let t=e=>{var n=e.reactions;if(n!==null)for(let e of n){var r=e.f;if(r&2)t(e);else{var i=e;r&4194320&&!this.async_deriveds.has(i)&&(this.#d.delete(i),E(i,h),this.schedule(i))}}};for(let e of this.current.keys())t(e);this.oncommit(()=>e.discard()),e.#S(),D=this,this.#g()}#b(e){for(var t=0;t!l.current.get(e)[1]&&!this.current.has(e));if(r.length===0)e&&l.discard();else if(t.length>0){if(e)for(let e of this.#p)l.unskip_effect(e,e=>{e.f&4194320?l.schedule(e):l.#b([e])});l.activate();var i=new Set,a=new Map;for(var o of t)_t(o,r,i,a);a=new Map;var s=[...l.current].filter(([e,t])=>{let n=this.current.get(e);return n?n[0]!==t[0]||n[1]!==t[1]:!0}).map(([e])=>e);if(s.length>0)for(let e of this.#l)!(e.f&155648)&&vt(e,s,a)&&(e.f&4194320?(E(e,h),l.schedule(e)):l.#u.add(e));if(l.#c.length>0&&!l.#m){l.apply();for(var c of l.#c)l.#_(c,[],[]);l.#c=[]}l.deactivate()}}}}increment(e,t){if(this.#a+=1,e){let e=this.#o.get(t)??0;this.#o.set(t,e+1)}}decrement(e,t){if(--this.#a,e){let e=this.#o.get(t)??0;e===1?this.#o.delete(t):this.#o.set(t,e-1)}this.#m||(this.#m=!0,Ye(()=>{this.#m=!1,this.linked&&this.flush()}))}transfer_effects(e,t){for(let t of e)this.#u.add(t);for(let e of t)this.#d.add(e);e.clear(),t.clear()}oncommit(e){this.#r.add(e)}ondiscard(e){this.#i.add(e)}settled(){return(this.#s??=p()).promise}static ensure(){if(D===null){let t=D=new e;!ct&&!st&&Ye(()=>{t.#e||t.flush()})}return D}apply(){if(!C||!this.is_fork&&this.#t===null&&this.#n===null){O=null;return}O=new Map;for(let[e,[t]]of this.current)O.set(e,t);for(let t=rt;t!==null;t=t.#n)if(!(t===this||t.is_fork)){var e=!1;if(t.id0)){Vt.clear();for(let e of k){if(e.f&24576)continue;let t=[e],n=e.parent;for(;n!==null;)k.has(n)&&(k.delete(n),t.push(n)),n=n.parent;for(let e=t.length-1;e>=0;e--){let n=t[e];n.f&24576||qn(n)}}k.clear()}}k=null}}function _t(e,t,n,r){if(!n.has(e)&&(n.add(e),e.reactions!==null))for(let i of e.reactions){let e=i.f;e&2?_t(i,t,n,r):e&4194320&&!(e&2048)&&vt(i,t,r)&&(E(i,h),yt(i))}}function vt(e,t,r){let i=r.get(e);if(i!==void 0)return i;if(e.deps!==null)for(let i of e.deps){if(n.call(t,i))return!0;if(i.f&2&&vt(i,t,r))return r.set(i,!0),!0}return r.set(e,!1),!1}function yt(e){D.schedule(e)}function bt(e,t){if(!(e.f&32&&e.f&1024)){e.f&2048?t.d.push(e):e.f&4096&&t.m.push(e),E(e,m);for(var n=e.first;n!==null;)bt(n,t),n=n.next}}function xt(e){E(e,m);for(var t=e.first;t!==null;)xt(t),t=t.next}function St(e){let t=0,n=Ut(0),r;return()=>{mn()&&(X(n),bn(()=>(t===0&&(r=Zn(()=>e(()=>Kt(n)))),t+=1,()=>{Ye(()=>{--t,t===0&&(r?.(),r=void 0,Kt(n))})})))}}var Ct=re|ie;function wt(e,t,n,r){new Tt(e,t,n,r)}var Tt=class{parent;is_pending=!1;transform_error;#e;#t=y?b:null;#n;#r;#i;#a=null;#o=null;#s=null;#c=null;#l=0;#u=0;#d=!1;#f=new Set;#p=new Set;#m=null;#h=St(()=>(this.#m=Ut(this.#l),()=>{this.#m=null}));constructor(e,t,n,r){this.#e=e,this.#n=t,this.#r=e=>{var t=W;t.b=this,t.f|=128,n(e)},this.parent=W.b,this.transform_error=r??this.parent?.transform_error??(e=>e),this.#i=xn(()=>{if(y){let e=this.#t;Fe();let t=e.data===`[!`;if(e.data.startsWith(`[?`)){let t=JSON.parse(e.data.slice(2));this.#_(t)}else t?this.#v():this.#g()}else this.#y()},Ct),y&&(this.#e=b)}#g(){try{this.#a=R(()=>this.#r(this.#e))}catch(e){this.error(e)}}#_(e){let t=this.#n.failed;t&&(this.#s=R(()=>{t(this.#e,()=>e,()=>()=>{})}))}#v(){let e=this.#n.pending;e&&(this.is_pending=!0,this.#o=R(()=>e(this.#e)),Ye(()=>{var e=this.#c=document.createDocumentFragment(),t=M();e.append(t),this.#a=this.#x(()=>R(()=>this.#r(t))),this.#u===0&&(this.#e.before(e),this.#c=null,Dn(this.#o,()=>{this.#o=null}),this.#b(D))}))}#y(){try{if(this.is_pending=this.has_pending_snippet(),this.#u=0,this.#l=0,this.#a=R(()=>{this.#r(this.#e)}),this.#u>0){var e=this.#c=document.createDocumentFragment();jn(this.#a,e);let t=this.#n.pending;this.#o=R(()=>t(this.#e))}else this.#b(D)}catch(e){this.error(e)}}#b(e){this.is_pending=!1,e.transfer_effects(this.#f,this.#p)}defer_effect(e){tt(e,this.#f,this.#p)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#n.pending}#x(e){var t=W,n=V,r=w;G(this.#i),U(this.#i),Ue(this.#i.ctx);try{return pt.ensure(),e()}catch(e){return Ze(e),null}finally{G(t),U(n),Ue(r)}}#S(e,t){if(!this.has_pending_snippet()){this.parent&&this.parent.#S(e,t);return}this.#u+=e,this.#u===0&&(this.#b(t),this.#o&&Dn(this.#o,()=>{this.#o=null}),this.#c&&=(this.#e.before(this.#c),null))}update_pending_count(e,t){this.#S(e,t),this.#l+=e,!(!this.#m||this.#d)&&(this.#d=!0,Ye(()=>{this.#d=!1,this.#m&&Wt(this.#m,this.#l)}))}get_effect_pending(){return this.#h(),X(this.#m)}error(e){if(!this.#n.onerror&&!this.#n.failed)throw e;D?.is_fork?(this.#a&&D.skip_effect(this.#a),this.#o&&D.skip_effect(this.#o),this.#s&&D.skip_effect(this.#s),D.oncommit(()=>{this.#C(e)})):this.#C(e)}#C(e){this.#a&&=(z(this.#a),null),this.#o&&=(z(this.#o),null),this.#s&&=(z(this.#s),null),y&&(x(this.#t),Ie(),x(Le()));var t=this.#n.onerror;let n=this.#n.failed;var r=!1,i=!1;let a=()=>{if(r){Ne();return}r=!0,i&&Oe(),this.#s!==null&&Dn(this.#s,()=>{this.#s=null}),this.#x(()=>{this.#y()})},o=e=>{try{i=!0,t?.(e,a),i=!1}catch(e){T(e,this.#i&&this.#i.parent)}n&&(this.#s=this.#x(()=>{try{return R(()=>{var t=W;t.b=this,t.f|=128,n(this.#e,()=>e,()=>a)})}catch(e){return T(e,this.#i.parent),null}}))};Ye(()=>{var t;try{t=this.transform_error(e)}catch(e){T(e,this.#i&&this.#i.parent);return}typeof t==`object`&&t&&typeof t.then==`function`?t.then(o,e=>T(e,this.#i&&this.#i.parent)):o(t)})}};function Et(e,t,n,r){let i=Ke()?At:Pt;var a=e.filter(e=>!e.settled);if(n.length===0&&a.length===0){r(t.map(i));return}var o=W,s=Dt(),c=a.length===1?a[0].promise:a.length>1?Promise.all(a.map(e=>e.promise)):null;function l(e){if(!(o.f&16384)){s();try{r(e)}catch(e){T(e,o)}Ot()}}var u=kt();if(n.length===0){c.then(()=>l(t.map(i))).finally(u);return}function d(){Promise.all(n.map(e=>Mt(e))).then(e=>l([...t.map(i),...e])).catch(e=>T(e,o)).finally(u)}c?c.then(()=>{s(),d(),Ot()}):d()}function Dt(){var e=W,t=V,n=w,r=D;return function(i=!0){G(e),U(t),Ue(n),i&&!(e.f&16384)&&(r?.activate(),r?.apply())}}function Ot(e=!0){G(null),U(null),Ue(null),e&&D?.deactivate()}function kt(){var e=W,t=e.b,n=D,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function At(e){var t=2|h;return W!==null&&(W.f|=ie),{ctx:w,deps:null,effects:null,equals:ze,f:t,fn:e,reactions:null,rv:0,v,wv:0,parent:W,ac:null}}var jt=Symbol(`obsolete`);function Mt(e,t,n){let r=W;r===null&&be();var i=void 0,a=Ut(v),o=!V,s=new Set;return yn(()=>{var t=W,n=p();i=n.promise;try{Promise.resolve(e()).then(n.resolve,e=>{e!==_e&&n.reject(e)}).finally(Ot)}catch(e){n.reject(e),Ot()}var c=D;if(o){if(t.f&32768)var l=kt();if(r.b?.is_rendered())c.async_deriveds.get(t)?.reject(jt);else for(let e of s.values())e.reject(jt);s.add(n),c.async_deriveds.set(t,n)}let u=(e,t=void 0)=>{l?.(),s.delete(n),t!==jt&&(c.activate(),t?(a.f|=le,Wt(a,t)):(a.f&8388608&&(a.f^=le),Wt(a,e)),c.deactivate())};n.promise.then(u,e=>u(null,e||`unknown`))}),hn(()=>{for(let e of s)e.reject(jt)}),new Promise(e=>{function t(n){function r(){n===i?e(a):t(i)}n.then(r,r)}t(i)})}function Nt(e){let t=At(e);return C||Fn(t),t}function Pt(e){let t=At(e);return t.equals=Ve,t}function Ft(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n0&&!Ht&&Gt()}return t}function Gt(){Ht=!1;for(let e of Bt){e.f&1024&&E(e,g);let t;try{t=Hn(e)}catch{t=!0}t&&qn(e)}Bt.clear()}function Kt(e){j(e,e.v+1)}function qt(e,t,n){var r=e.reactions;if(r!==null)for(var i=Ke(),a=r.length,o=0;o{if(zn===d)return e();var t=V,n=zn;U(null),Bn(d);var r=e();return U(t),Bn(n),r};return i&&r.set(`length`,A(t.length,u)),new Proxy(t,{defineProperty(e,t,n){(!(`value`in n)||n.configurable===!1||n.enumerable===!1||n.writable===!1)&&Te();var i=r.get(t);return i===void 0?f(()=>{var e=A(n.value,u);return r.set(t,e),e}):j(i,n.value,!0),!0},deleteProperty(e,t){var n=r.get(t);if(n===void 0){if(t in e){let e=f(()=>A(v,u));r.set(t,e),Kt(o)}}else j(n,v),Kt(o);return!0},get(e,n,i){if(n===ue)return t;var o=r.get(n),s=n in e;if(o===void 0&&(!s||a(e,n)?.writable)&&(o=f(()=>A(Jt(s?e[n]:v),u)),r.set(n,o)),o!==void 0){var c=X(o);return c===v?void 0:c}return Reflect.get(e,n,i)},getOwnPropertyDescriptor(e,t){var n=Reflect.getOwnPropertyDescriptor(e,t);if(n&&`value`in n){var i=r.get(t);i&&(n.value=X(i))}else if(n===void 0){var a=r.get(t),o=a?.v;if(a!==void 0&&o!==v)return{enumerable:!0,configurable:!0,value:o,writable:!0}}return n},has(e,t){if(t===ue)return!0;var n=r.get(t),i=n!==void 0&&n.v!==v||Reflect.has(e,t);return(n!==void 0||W!==null&&(!i||a(e,t)?.writable))&&(n===void 0&&(n=f(()=>A(i?Jt(e[t]):v,u)),r.set(t,n)),X(n)===v)?!1:i},set(e,t,n,s){var c=r.get(t),l=t in e;if(i&&t===`length`)for(var d=n;dA(v,u)),r.set(d+``,p)):j(p,v)}if(c===void 0)(!l||a(e,t)?.writable)&&(c=f(()=>A(void 0,u)),j(c,Jt(n)),r.set(t,c));else{l=c.v!==v;var m=f(()=>Jt(n));j(c,m)}var h=Reflect.getOwnPropertyDescriptor(e,t);if(h?.set&&h.set.call(s,n),!l){if(i&&typeof t==`string`){var g=r.get(`length`),_=Number(t);Number.isInteger(_)&&_>=g.v&&j(g,_+1)}Kt(o)}return!0},ownKeys(e){X(o);var t=Reflect.ownKeys(e).filter(e=>{var t=r.get(e);return t===void 0||t.v!==v});for(var[n,i]of r)i.v!==v&&!(n in e)&&t.push(n);return t},setPrototypeOf(){Ee()}})}new Set([`copyWithin`,`fill`,`pop`,`push`,`reverse`,`shift`,`sort`,`splice`,`unshift`]);var Yt,Xt,Zt,Qt;function $t(){if(Yt===void 0){Yt=window,Xt=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;Zt=a(t,`firstChild`).get,Qt=a(t,`nextSibling`).get,u(e)&&(e[pe]=void 0,e[fe]=null,e[me]=void 0,e.__e=void 0),u(n)&&(n[he]=void 0)}}function M(e=``){return document.createTextNode(e)}function en(e){return Zt.call(e)}function N(e){return Qt.call(e)}function P(e,t){if(!y)return en(e);var n=en(b);if(n===null)n=b.appendChild(M());else if(t&&n.nodeType!==3){var r=M();return n?.before(r),x(r),r}return t&&on(n),x(n),n}function tn(e,t=!1){if(!y){var n=en(e);return n instanceof Comment&&n.data===``?N(n):n}if(t){if(b?.nodeType!==3){var r=M();return b?.before(r),x(r),r}on(b)}return b}function F(e,t=1,n=!1){let r=y?b:e;for(var i;t--;)i=r,r=N(r);if(!y)return r;if(n){if(r?.nodeType!==3){var a=M();return r===null?i?.after(a):r.before(a),x(a),a}on(r)}return x(r),r}function nn(e){e.textContent=``}function rn(){return!C||k!==null?!1:(W.f&te)!==0}function an(e,t,n){return t==null||t===`http://www.w3.org/1999/xhtml`?n?document.createElement(e,{is:n}):document.createElement(e):n?document.createElementNS(t,e,{is:n}):document.createElementNS(t,e)}function on(e){if(e.nodeValue.length<65536)return;let t=e.nextSibling;for(;t!==null&&t.nodeType===3;)t.remove(),e.nodeValue+=t.nodeValue,t=e.nextSibling}function sn(e){y&&en(e)!==null&&nn(e)}var cn=!1;function ln(){cn||(cn=!0,document.addEventListener(`reset`,e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(let t of e.target.elements)t[ge]?.()})},{capture:!0}))}function un(e){var t=V,n=W;U(null),G(null);try{return e()}finally{U(t),G(n)}}function dn(e,t,n,r=n){e.addEventListener(t,()=>un(n));let i=e[ge];i?e[ge]=()=>{i(),r(!0)}:e[ge]=()=>r(!0),ln()}function fn(e){W===null&&(V===null&&Ce(e),Se()),B&&xe(e)}function pn(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function I(e,t){var n=W;n!==null&&n.f&8192&&(e|=_);var r={ctx:w,deps:null,nodes:null,f:e|h|512,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};D?.register_created_effect(r);var i=r;if(e&4)lt===null?pt.ensure().schedule(r):lt.push(r);else if(t!==null){try{qn(r)}catch(e){throw z(r),e}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&524288)&&(i=i.first,e&16&&e&65536&&i!==null&&(i.f|=re))}if(i!==null&&(i.parent=n,n!==null&&pn(i,n),V!==null&&V.f&2&&!(e&64))){var a=V;(a.effects??=[]).push(i)}return r}function mn(){return V!==null&&!H}function hn(e){let t=I(8,null);return E(t,m),t.teardown=e,t}function gn(e){fn(`$effect`);var t=W.f;if(!V&&t&32&&w!==null&&!w.i){var n=w;(n.e??=[]).push(e)}else return _n(e)}function _n(e){return I(4|ae,e)}function vn(e){pt.ensure();let t=I(64|ie,e);return(e={})=>new Promise(n=>{e.outro?Dn(t,()=>{z(t),n(void 0)}):(z(t),n(void 0))})}function yn(e){return I(ce|ie,e)}function bn(e,t=0){return I(8|t,e)}function L(e,t=[],n=[],r=[]){Et(r,t,n,t=>{I(8,()=>e(...t.map(X)))})}function xn(e,t=0){return I(16|t,e)}function R(e){return I(32|ie,e)}function Sn(e){var t=e.teardown;if(t!==null){let e=B,n=V;Pn(!0),U(null);try{t.call(null)}finally{Pn(e),U(n)}}}function Cn(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){let e=n.ac;e!==null&&un(()=>{e.abort(_e)});var r=n.next;n.f&64?n.parent=null:z(n,t),n=r}}function wn(e){for(var t=e.first;t!==null;){var n=t.next;t.f&32||z(t),t=n}}function z(e,t=!0){var n=!1;(t||e.f&262144)&&e.nodes!==null&&e.nodes.end!==null&&(Tn(e.nodes.start,e.nodes.end),n=!0),E(e,ne),Cn(e,t&&!n),Kn(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)e.stop();Sn(e),e.f^=ne,e.f|=ee;var i=e.parent;i!==null&&i.first!==null&&En(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function Tn(e,t){for(;e!==null;){var n=e===t?null:N(e);e.remove(),e=n}}function En(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function Dn(e,t,n=!0){var r=[];On(e,r,!0);var i=()=>{n&&z(e),t&&t()},a=r.length;if(a>0){var o=()=>--a||i();for(var s of r)s.out(o)}else i()}function On(e,t,n){if(!(e.f&8192)){e.f^=_;var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)(e.is_global||n)&&t.push(e);for(var i=e.first;i!==null;){var a=i.next;if(!(i.f&64)){var o=(i.f&65536)!=0||(i.f&32)!=0&&(e.f&16)!=0;On(i,t,o?n:!1)}i=a}}}function kn(e){An(e,!0)}function An(e,t){if(e.f&8192){e.f^=_,e.f&1024||(E(e,h),pt.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=(n.f&65536)!=0||(n.f&32)!=0;An(n,i?t:!1),n=r}var a=e.nodes&&e.nodes.t;if(a!==null)for(let e of a)(e.is_global||t)&&e.in()}}function jn(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:N(n);t.append(n),n=i}}var Mn=null,Nn=!1,B=!1;function Pn(e){B=e}var V=null,H=!1;function U(e){V=e}var W=null;function G(e){W=e}var K=null;function Fn(e){V!==null&&(!C||V.f&2)&&(K??=new Set).add(e)}var q=null,J=0,Y=null;function In(e){Y=e}var Ln=1,Rn=0,zn=Rn;function Bn(e){zn=e}function Vn(){return++Ln}function Hn(e){var t=e.f;if(t&2048)return!0;if(t&2&&(e.f&=~oe),t&4096){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}t&512&&O===null&&E(e,m)}return!1}function Un(e,t,n=!0){var r=e.reactions;if(r!==null&&!(!C&&K!==null&&K.has(e)))for(var i=0;i{e.ac.abort(_e)}),e.ac=null);try{e.f|=se;var u=e.fn,d=u();e.f|=te;var f=e.deps,p=D?.is_fork;if(q!==null){var m;if(p||Kn(e,J),f!==null&&J>0)for(f.length=J+q.length,m=0;m{requestAnimationFrame(()=>e()),setTimeout(()=>e())});await Promise.resolve(),mt()}function X(e){var t=(e.f&2)!=0;if(Mn?.add(e),V!==null&&!H&&!(W!==null&&W.f&16384)&&(K===null||!K.has(e))){var r=V.deps;if(V.f&2097152)e.rvn?.call(this,e))}return e.startsWith(`pointer`)||e.startsWith(`touch`)||e===`wheel`?Ye(()=>{t.addEventListener(e,i,r)}):t.addEventListener(e,i,r),i}function ir(e,t,n,r,i){var a={capture:r,passive:i},o=rr(e,t,n,a);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&hn(()=>{t.removeEventListener(e,o,a)})}function ar(e,t,n){(t[er]??={})[e]=n}function or(e){for(var t=0;t{throw e});throw p}}finally{e[er]=t,delete e.currentTarget,U(d),G(f)}}}var lr=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy(`svelte-trusted-html`,{createHTML:e=>e});function ur(e){return lr?.createHTML(e)??e}function dr(e){var t=an(`template`);return t.innerHTML=ur(e.replaceAll(``,``)),t.content}function fr(e,t){var n=W;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function Z(e,t){var n=(t&1)!=0,r=(t&2)!=0,i,a=!e.startsWith(``);return()=>{if(y)return fr(b,null),b;i===void 0&&(i=dr(a?e:``+e),n||(i=en(i)));var t=r||Xt?document.importNode(i,!0):i.cloneNode(!0);if(n){var o=en(t),s=t.lastChild;fr(o,s)}else fr(t,t);return t}}function pr(e=``){if(!y){var t=M(e+``);return fr(t,t),t}var n=b;return n.nodeType===3?on(n):(n.before(n=M()),x(n)),fr(n,n),n}function Q(e,t){if(y){var n=W;(!(n.f&32768)||n.nodes.end===null)&&(n.nodes.end=b),Fe();return}e!==null&&e.before(t)}function mr(e,t){var n=t==null?``:typeof t==`object`?`${t}`:t;n!==(e[he]??=e.nodeValue)&&(e[he]=n,e.nodeValue=`${n}`)}function hr(e,t){return _r(e,t)}var gr=new Map;function _r(e,{target:t,anchor:n,props:i={},events:a,context:o,intro:s=!0,transformError:c}){$t();var l=void 0,u=vn(()=>{var s=n??t.appendChild(M());wt(s,{pending:()=>{}},t=>{We({});var n=w;if(o&&(n.c=o),a&&(i.$$events=a),y&&fr(t,null),l=e(t,i)||{},y&&(W.nodes.end=b,b===null||b.nodeType!==8||b.data!==`]`))throw Me(),ke;Ge()},c);var u=new Set,d=e=>{for(var n=0;n{for(var e of u)for(let n of[t,document]){var r=gr.get(n),i=r.get(e);--i==0?(n.removeEventListener(e,cr),r.delete(e),r.size===0&&gr.delete(n)):r.set(e,i)}nr.delete(d),s!==n&&s.parentNode?.removeChild(s)}});return vr.set(l,u),l}var vr=new WeakMap,yr=class{anchor;#e=new Map;#t=new Map;#n=new Map;#r=new Set;#i=!0;constructor(e,t=!0){this.anchor=e,this.#i=t}#a=e=>{if(this.#e.has(e)){var t=this.#e.get(e),n=this.#t.get(t);if(n)kn(n),this.#r.delete(t);else{var r=this.#n.get(t);r&&(kn(r.effect),this.#t.set(t,r.effect),this.#n.delete(t),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),n=r.effect)}for(let[t,n]of this.#e){if(this.#e.delete(t),t===e)break;let r=this.#n.get(n);r&&(z(r.effect),this.#n.delete(n))}for(let[e,r]of this.#t){if(e===t||this.#r.has(e))continue;let i=()=>{if(Array.from(this.#e.values()).includes(e)){var t=document.createDocumentFragment();jn(r,t),t.append(M()),this.#n.set(e,{effect:r,fragment:t})}else z(r);this.#r.delete(e),this.#t.delete(e)};this.#i||!n?(this.#r.add(e),Dn(r,i,!1)):i()}}};#o=e=>{this.#e.delete(e);let t=Array.from(this.#e.values());for(let[e,n]of this.#n)t.includes(e)||(z(n.effect),this.#n.delete(e))};ensure(e,t){var n=D,r=rn();if(t&&!this.#t.has(e)&&!this.#n.has(e))if(r){var i=document.createDocumentFragment(),a=M();i.append(a),this.#n.set(e,{effect:R(()=>t(a)),fragment:i})}else this.#t.set(e,R(()=>t(this.anchor)));if(this.#e.set(n,e),r){for(let[t,r]of this.#t)t===e?n.unskip_effect(r):n.skip_effect(r);for(let[t,r]of this.#n)t===e?n.unskip_effect(r.effect):n.skip_effect(r.effect);n.oncommit(this.#a),n.ondiscard(this.#o)}else y&&(this.anchor=b),this.#a(n)}};function $(e,t,n=!1){var r;y&&(r=b,Fe());var i=new yr(e),a=n?re:0;function o(e,t){if(y){var n=Re(r);if(e!==parseInt(n.substring(1))){var a=Le();x(a),i.anchor=a,Pe(!1),i.ensure(e,t),Pe(!0);return}}i.ensure(e,t)}xn(()=>{var e=!1;t((t,n=0)=>{e=!0,o(n,t)}),e||o(-1,null)},a)}var br=[...` +\r\f\xA0\v`];function xr(e,t,n){var r=e==null?``:``+e;if(t&&(r=r?r+` `+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+` `+i:i;else if(r.length)for(var a=i.length,o=0;(o=r.indexOf(i,o))>=0;){var s=o+a;(o===0||br.includes(r[o-1]))&&(s===r.length||br.includes(r[s]))?r=(o===0?``:r.substring(0,o))+r.substring(s+1):o=s}}return r===``?null:r}function Sr(e,t,n,r,i,a){var o=e[pe];if(y||o!==n||o===void 0){var s=xr(n,r,a);(!y||s!==e.getAttribute(`class`))&&(s==null?e.removeAttribute(`class`):t?e.className=s:e.setAttribute(`class`,s)),e[pe]=n}else if(a&&i!==a)for(var c in a){var l=!!a[c];(i==null||l!==!!i[c])&&e.classList.toggle(c,l)}return a}var Cr=Symbol(`is custom element`),wr=Symbol(`is html`),Tr=ve?`link`:`LINK`;function Er(e,t,n,r){var i=Dr(e);y&&(i[t]=e.getAttribute(t),t===`src`||t===`srcset`||t===`href`&&e.nodeName===Tr)||i[t]!==(i[t]=n)&&(t===`loading`&&(e[de]=n),n==null?e.removeAttribute(t):typeof n!=`string`&&kr(e).includes(t)?e[t]=n:e.setAttribute(t,n))}function Dr(e){return e[fe]??={[Cr]:e.nodeName.includes(`-`),[wr]:e.namespaceURI===Ae}}var Or=new Map;function kr(e){var t=e.getAttribute(`is`)||e.nodeName,n=Or.get(t);if(n)return n;Or.set(t,n=[]);for(var r,i=e,a=Element.prototype;a!==i;){for(var s in r=o(i),r)r[s].set&&s!==`innerHTML`&&s!==`textContent`&&s!==`innerText`&&n.push(s);i=l(i)}return n}function Ar(e,t,n=t){var r=new WeakSet;dn(e,`input`,async i=>{var a=i?e.defaultValue:e.value;if(a=jr(e)?Mr(a):a,n(a),D!==null&&r.add(D),await Jn(),a!==(a=t())){var o=e.selectionStart,s=e.selectionEnd,c=e.value.length;if(e.value=a??``,s!==null){var l=e.value.length;o===s&&s===c&&l>c?(e.selectionStart=l,e.selectionEnd=l):(e.selectionStart=o,e.selectionEnd=Math.min(s,l))}}}),(y&&e.defaultValue!==e.value||Zn(t)==null&&e.value)&&(n(jr(e)?Mr(e.value):e.value),D!==null&&r.add(D)),bn(()=>{var n=t();if(e===document.activeElement){var i=C?at:D;if(r.has(i))return}jr(e)&&n===Mr(e.value)||e.type===`date`&&!n&&!e.value||n!==e.value&&(e.value=n??``)})}function jr(e){var t=e.type;return t===`number`||t===`range`}function Mr(e){return e===``?null:+e}function Nr(e){w===null&&ye(`onMount`),He&&w.l!==null?Fr(w).m.push(e):gn(()=>{let t=Zn(e);if(typeof t==`function`)return t})}function Pr(e){w===null&&ye(`onDestroy`),Nr(()=>()=>Zn(e))}function Fr(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}typeof window<`u`&&((window.__svelte??={}).v??=new Set).add(`5`);var Ir=Z(`Upload`),Lr=Z(`

Uploads are currently unavailable from your network location.

`),Rr=Z(`

Share scratch files and text quickly

Scratchbox is a minimal temporary upload service. Every upload creates one scratch with an expiration + timestamp. It supports plain text and single file uploads, plus direct raw retrieval for automation.

No accounts, no sessions, no friction. Create a scratch, grab the URL, and share it.

`),zr=Z(`

Loading scratch...

`),Br=Z(`
`),Vr=Z(``),Hr=Z(`

This scratch looks binary and is not rendered in-page. Use the raw link.

`),Ur=Z(`
 
`),Wr=Z(`

Expires in:

Download

`,1),Gr=Z(`
`),Kr=Z(`

`),qr=Z(`
Uploads are disabled for your current IP based on server allowlist settings.
`),Jr=Z(`
`),Yr=Z(`

Upload one file per scratch.

`),Xr=Z(`
Upload mode
`),Zr=Z(`

Upload

Create an unauthenticated scratch. It expires automatically.

`),Qr=Z(`

Scratch expired

`),$r=Z(`

Page not found

The requested path does not exist.

`),ei=Z(`

Scratchbox - simple temporary uploads

`);function ti(e,t){We(t,!0);let n=A(`text`),r=A(``),i=A(null),a=A(!1),o=A(``),s=A(0),c=A(!0),l=A(``),u=A(`home`),d=A(``),f=A(!1),p=A(``),m=A(null),h=A(``),g=A(!1),_=A(!1),ee=A(!1),te=A(Jt(Date.now())),ne=null,re=Nt(()=>ce(X(s),!0));Nr(async()=>{ne=window.setInterval(()=>{j(te,Date.now(),!0)},1e3),ie(),await ae(),X(u)===`view`&&await oe()}),Pr(()=>{ne!==null&&(window.clearInterval(ne),ne=null)});function ie(){let e=window.location.pathname;if(e===`/`||e===``){j(u,`home`),j(d,``);return}if(e===`/u`||e===`/u/`){j(u,`upload`),j(d,``);return}if(!e.startsWith(`/s/`)){j(u,`notfound`),j(d,``);return}let t=e.slice(3).trim();if(t.length===0){j(u,`notfound`),j(d,``);return}j(u,`view`),j(d,decodeURIComponent(t),!0)}async function ae(){j(l,``);try{let e=await fetch(`/api/config`);if(!e.ok)throw Error(`failed to load UI config (${e.status})`);let t=await e.json(),n=Number.parseInt(String(t.max_upload_size_bytes??`0`),10);j(s,Number.isFinite(n)?n:0,!0),j(c,!!t.upload_allowed,!0)}catch(e){j(l,e instanceof Error?e.message:String(e),!0),j(s,0),j(c,!0)}}async function oe(){if(!X(d)){j(p,`Scratch id is missing.`);return}j(f,!0),j(p,``),j(m,null),j(h,``),j(g,!1),j(_,!1),j(ee,!1);try{let e=await fetch(`/api/scratch/${encodeURIComponent(X(d))}`);if(!e.ok){if(e.status===404||e.status===410){j(u,`expired`),j(p,`This scratch is no longer available.`);return}let t=await e.text();throw Error(t||`failed to load scratch metadata (${e.status})`)}j(m,await e.json(),!0);let t=await fetch(`/api/raw/${encodeURIComponent(X(d))}`);if(!t.ok){let e=await t.text();throw Error(e||`failed to load scratch content (${t.status})`)}let n=t.headers.get(`content-type`)??``;le(n)?(j(h,await t.text(),!0),j(g,!1),j(_,!1)):ue(n)?(j(_,!0),j(g,!1)):(j(_,!1),j(g,!0))}catch(e){j(p,e instanceof Error?e.message:String(e),!0)}finally{j(f,!1)}}function se(e){return e.replace(/\.0+$/,``).replace(/(\.\d*[1-9])0+$/,`$1`)}function ce(e,t){if(!Number.isFinite(e)||e<=0)return`0 bytes`;let n=t?1024:1e3,r=t?[`KiB`,`MiB`,`GiB`]:[`KB`,`MB`,`GB`];if(e=n&&a=10?i.toFixed(1):i.toFixed(2))} ${r[a]}`}function le(e){let t=String(e).toLowerCase();return t.startsWith(`text/`)?!0:t.startsWith(`application/json`)||t.startsWith(`application/xml`)||t.startsWith(`application/javascript`)||t.startsWith(`application/x-www-form-urlencoded`)}function ue(e){return String(e).toLowerCase().startsWith(`image/`)}gn(()=>{if(X(ee)||X(u)!==`view`||!X(m)?.expires_at)return;let e=Date.parse(String(X(m).expires_at));Number.isFinite(e)&&X(te)>=e+1e4&&(j(ee,!0),window.location.reload())});function de(e,t){let n=Date.parse(String(e??``));if(!Number.isFinite(n))return`unknown`;let r=Math.max(0,n-t);if(r===0)return`expired`;let i=Math.floor(r/1e3),a=Math.floor(i/3600),o=Math.floor(i%3600/60),s=i%60;return a>0?`${a}h ${o}m ${s}s`:o>0?`${o}m ${s}s`:`${s}s`}function fe(e){X(a)||e===X(n)||(j(n,e,!0),j(o,``),X(n)===`text`?j(i,null):j(r,``))}function pe(e){j(i,e.currentTarget.files?.[0]??null,!0)}async function me(e){if(e.preventDefault(),X(a))return;if(!X(c)){j(o,`Uploads are not available from your network location.`);return}j(o,``);let t=X(r).trim();if(X(n)===`text`){if(!t){j(o,`Enter text content before creating a scratch.`);return}}else{if(!X(i)){j(o,`Choose a file before creating a scratch.`);return}if(X(s)>0&&X(i).size>X(s)){j(o,`Selected file is too large (${ce(X(i).size,!0)}). Max upload size is ${X(re)}.`);return}}j(a,!0);try{let e;if(X(n)===`file`){let t=new FormData;t.append(`file`,X(i)),e=await fetch(`/api/scratch`,{method:`POST`,body:t})}else e=await fetch(`/api/scratch`,{method:`POST`,headers:{"Content-Type":`text/plain; charset=utf-8`},body:t});if(!e.ok){let t=await e.text();throw Error(t||`request failed (${e.status})`)}let r=await e.json(),a=typeof r?.view_url==`string`?r.view_url:``;if(!a)throw Error(`scratch created but view URL is missing`);window.location.assign(a);return}catch(e){X(n)===`file`&&e instanceof TypeError?j(o,`Upload failed before the server returned a response. Max upload size is ${X(re)}.`):j(o,e instanceof Error?e.message:String(e),!0)}finally{j(a,!1)}}var he=ei(),ge=P(he),_e=F(P(ge),2),ve=F(P(_e),2),ye=e=>{Q(e,Ir())};$(ve,e=>{X(c)&&e(ye)}),S(_e),S(ge);var be=F(ge,2),xe=e=>{var t=Rr(),n=F(P(t),6),r=e=>{Q(e,Lr())};$(n,e=>{X(c)||e(r)}),S(t),Q(e,t)},Se=e=>{var t=Gr(),n=P(t),r=e=>{Q(e,zr())},i=e=>{var t=Br(),n=P(t,!0);S(t),L(()=>mr(n,X(p))),Q(e,t)},a=e=>{var t=Wr(),n=tn(t),r=e=>{var t=Vr();L(()=>{Er(t,`src`,X(m).raw_url),Er(t,`alt`,`Scratch ${X(d)}`)}),Q(e,t)},i=e=>{Q(e,Hr())},a=e=>{var t=Ur(),n=P(t,!0);S(t),L(()=>mr(n,X(h))),Q(e,t)};$(n,e=>{X(_)?e(r):X(g)?e(i,1):e(a,-1)});var o=F(n,2),s=F(P(o));S(o);var c=F(o,2),l=P(c);S(c),L(e=>{mr(s,` ${e??``}`),Er(l,`href`,X(m).raw_url)},[()=>de(X(m).expires_at,X(te))]),Q(e,t)};$(n,e=>{X(f)?e(r):X(p)?e(i,1):X(m)&&e(a,2)}),S(t),Q(e,t)},Ce=e=>{var t=Zr(),i=F(P(t),4),s=P(i);S(i);var u=F(i,2),d=e=>{var t=Kr(),n=P(t);S(t),L(()=>mr(n,`Could not load UI config: ${X(l)??``}`)),Q(e,t)};$(u,e=>{X(l)&&e(d)});var f=F(u,2),p=e=>{Q(e,qr())},m=e=>{var t=Xr(),i=P(t),o=F(P(i),2),s=P(o);let c;var l=F(s,2);let u;S(o),S(i);var d=F(i,2),f=e=>{var t=Jr(),n=F(P(t),2);sn(n),S(t),L(()=>n.disabled=X(a)),Ar(n,()=>X(r),e=>j(r,e)),Q(e,t)},p=e=>{var t=Yr(),n=F(P(t),2);Ie(2),S(t),L(()=>n.disabled=X(a)),ar(`change`,n,pe),Q(e,t)};$(d,e=>{X(n)===`text`?e(f):e(p,-1)});var m=F(d,2),h=P(m),g=e=>{Q(e,pr(`Creating...`))},_=e=>{Q(e,pr(`Create scratch`))};$(h,e=>{X(a)?e(g):e(_,-1)}),S(m),S(t),L(()=>{Er(s,`aria-pressed`,X(n)===`text`),c=Sr(s,1,`svelte-1n46o8q`,null,c,{"mode-button":!0,"is-active":X(n)===`text`}),Er(l,`aria-pressed`,X(n)===`file`),u=Sr(l,1,`svelte-1n46o8q`,null,u,{"mode-button":!0,"is-active":X(n)===`file`}),m.disabled=X(a)}),ir(`submit`,t,me),ar(`click`,s,()=>fe(`text`)),ar(`click`,l,()=>fe(`file`)),Q(e,t)};$(f,e=>{X(c)?e(m,-1):e(p)});var h=F(f,2),g=e=>{var t=Br(),n=P(t,!0);S(t),L(()=>mr(n,X(o))),Q(e,t)};$(h,e=>{X(o)&&e(g)}),S(t),L(()=>mr(s,`Max upload size: ${X(re)??``}`)),Q(e,t)},we=e=>{var t=Qr(),n=F(P(t),2),r=P(n,!0);S(n),S(t),L(()=>mr(r,X(p)||`The scratch you requested is no longer available because its retention time elapsed.`)),Q(e,t)},Te=e=>{Q(e,$r())};$(be,e=>{X(u)===`home`?e(xe):X(u)===`view`?e(Se,1):X(u)===`upload`?e(Ce,2):X(u)===`expired`?e(we,3):e(Te,-1)}),Ie(2),S(he),Q(e,he),Ge()}or([`click`,`change`]);var ni=document.getElementById(`app`);ni&&hr(ti,{target:ni}); \ No newline at end of file diff --git a/web/static/assets/index-C05A_xhw.css b/web/static/assets/index-C05A_xhw.css new file mode 100644 index 0000000..4116c29 --- /dev/null +++ b/web/static/assets/index-C05A_xhw.css @@ -0,0 +1 @@ +body{color:#e7ebf3;background:#0b0e14;margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.container.svelte-1n46o8q{max-width:960px;margin:0 auto;padding:2rem 1.25rem 3rem}.site-header.svelte-1n46o8q,.site-footer.svelte-1n46o8q,.panel.svelte-1n46o8q{background:#101726;border:1px solid #2a3243;border-radius:10px}.site-header.svelte-1n46o8q,.site-footer.svelte-1n46o8q{padding:1rem 1.1rem}.site-header.svelte-1n46o8q{justify-content:space-between;align-items:center;gap:1rem;display:flex}.brand-row.svelte-1n46o8q{flex-direction:column;gap:.1rem;display:flex}.brand.svelte-1n46o8q{color:#f5f7fc;font-size:1.3rem;font-weight:700;text-decoration:none}.tagline.svelte-1n46o8q{color:#b1b7c7;font-size:.95rem}.site-nav.svelte-1n46o8q{gap:.85rem;display:flex}.panel.svelte-1n46o8q{margin-top:1rem;padding:1.2rem}.site-footer.svelte-1n46o8q{color:#b1b7c7;margin-top:1rem}.helper.svelte-1n46o8q{color:#b1b7c7;margin-top:.5rem}.link-button.svelte-1n46o8q{color:#f2f5fb;background:#213f75;border:1px solid #2f67c8;border-radius:8px;justify-content:center;align-items:center;padding:.45rem .8rem;font-weight:600;text-decoration:none;display:inline-flex}.nav-button.svelte-1n46o8q{background:#162640;border-color:#355487;font-weight:500}.warning.svelte-1n46o8q{color:#f4c16e}.mode-picker.svelte-1n46o8q,.input-panel.svelte-1n46o8q,#error.svelte-1n46o8q{margin-top:1rem}.mode-picker.svelte-1n46o8q{border:1px solid #2a3243;border-radius:8px;padding:.75rem}.mode-buttons.svelte-1n46o8q{gap:.5rem;display:flex}.mode-button.svelte-1n46o8q{color:#e7ebf3;cursor:pointer;background:#121826;border:1px solid #2a3243;border-radius:8px;padding:.4rem .8rem}.mode-button.is-active.svelte-1n46o8q{background:#2b4b89;border-color:#4a75c4}textarea.svelte-1n46o8q,input[type=file].svelte-1n46o8q{color:#e7ebf3;background:#121826;border:1px solid #2a3243;border-radius:8px;width:100%;margin-top:.5rem;padding:.7rem}button[type=submit].svelte-1n46o8q{color:#f2f5fb;cursor:pointer;background:#2b4b89;border:1px solid #2f67c8;border-radius:8px;margin-top:1rem;padding:.6rem 1rem}button[disabled].svelte-1n46o8q{opacity:.65;cursor:not-allowed}pre.svelte-1n46o8q{white-space:pre-wrap;background:#101726;border:1px solid #2a3243;border-radius:8px;max-width:100%;padding:1rem;overflow:auto}.scratch-image.svelte-1n46o8q{background:#0b0e14;border:1px solid #2a3243;border-radius:8px;max-width:100%;max-height:70vh;display:block}.error.svelte-1n46o8q{color:#ff9b9b} diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..3c9f2a4 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,13 @@ + + + + + + Scratchbox + + + + +
+ + diff --git a/web/templates/index.html b/web/templates/index.html deleted file mode 100644 index 6fb3b05..0000000 --- a/web/templates/index.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - Scratchbox - - - -
-

Scratchbox

-

Create an unauthenticated scratch. It expires automatically.

- -
- - - -
- - - - -
- - - - diff --git a/web/templates/view.html b/web/templates/view.html deleted file mode 100644 index 41b6c75..0000000 --- a/web/templates/view.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - Scratch {{ .ID }} - - - -
-

Scratch {{ .ID }}

-

Content type: {{ .ContentType }}

-

Expires at: {{ .ExpiresAt }}

-

Open raw

- - {{ if .Binary }} -

This scratch looks binary and is not rendered in-page. Use the raw link.

- {{ else }} -
{{ .Content }}
- {{ if .Truncated }} -

Preview truncated to 2MB. Open raw for full content.

- {{ end }} - {{ end }} -
- - diff --git a/web/ui/index.html b/web/ui/index.html new file mode 100644 index 0000000..bb11e60 --- /dev/null +++ b/web/ui/index.html @@ -0,0 +1,12 @@ + + + + + + Scratchbox + + +
+ + + diff --git a/web/ui/package-lock.json b/web/ui/package-lock.json new file mode 100644 index 0000000..3074f92 --- /dev/null +++ b/web/ui/package-lock.json @@ -0,0 +1,1173 @@ +{ + "name": "ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "svelte": "^5.56.0", + "vite": "^8.0.14" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.0.tgz", + "integrity": "sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.9", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/ui/package.json b/web/ui/package.json new file mode 100644 index 0000000..063df2b --- /dev/null +++ b/web/ui/package.json @@ -0,0 +1,13 @@ +{ + "name": "scratchbox-web-ui", + "private": true, + "type": "module", + "scripts": { + "build": "vite build" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "svelte": "^5.56.0", + "vite": "^8.0.14" + } +} diff --git a/web/ui/src/App.svelte b/web/ui/src/App.svelte new file mode 100644 index 0000000..a3c426f --- /dev/null +++ b/web/ui/src/App.svelte @@ -0,0 +1,641 @@ + + +
+ + + {#if routeMode === "home"} +
+

Share scratch files and text quickly

+

+ Scratchbox is a minimal temporary upload service. Every upload creates one scratch with an expiration + timestamp. It supports plain text and single file uploads, plus direct raw retrieval for automation. +

+

+ No accounts, no sessions, no friction. Create a scratch, grab the URL, and share it. +

+ {#if !uploadAllowed} +

Uploads are currently unavailable from your network location.

+ {/if} +
+ {:else if routeMode === "view"} +
+ {#if viewLoading} +

Loading scratch...

+ {:else if viewError} +
{viewError}
+ {:else if viewMeta} + {#if viewIsImage} + {`Scratch + {:else if viewIsBinary} +

This scratch looks binary and is not rendered in-page. Use the raw link.

+ {:else} +
{viewContent}
+ {/if} + +

Expires in: {expiresInText(viewMeta.expires_at, nowMs)}

+

Download

+ {/if} +
+ {:else if routeMode === "upload"} +
+

Upload

+

Create an unauthenticated scratch. It expires automatically.

+

Max upload size: {maxUploadSizeDisplay}

+ {#if configError} +

Could not load UI config: {configError}

+ {/if} + {#if !uploadAllowed} +
+ Uploads are disabled for your current IP based on server allowlist settings. +
+ {:else} +
+
+ Upload mode +
+ + +
+
+ + {#if mode === "text"} +
+ + +
+ {:else} +
+ + +

Upload one file per scratch.

+
+ {/if} + + +
+ {/if} + + {#if errorMessage} +
{errorMessage}
+ {/if} +
+ {:else if routeMode === "expired"} +
+

Scratch expired

+

{viewError || "The scratch you requested is no longer available because its retention time elapsed."}

+
+ {:else} +
+

Page not found

+

The requested path does not exist.

+
+ {/if} + +
+

Scratchbox - simple temporary uploads

+
+
+ + diff --git a/web/ui/src/main.js b/web/ui/src/main.js new file mode 100644 index 0000000..456ec07 --- /dev/null +++ b/web/ui/src/main.js @@ -0,0 +1,10 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; + +const target = document.getElementById("app"); + +if (target) { + mount(App, { + target + }); +} diff --git a/web/ui/vite.config.js b/web/ui/vite.config.js new file mode 100644 index 0000000..46d8f4b --- /dev/null +++ b/web/ui/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +export default defineConfig({ + plugins: [svelte()], + build: { + outDir: "../static", + emptyOutDir: true + } +});