Compare commits
4 Commits
1.0.3
..
ed95ce8295
| Author | SHA1 | Date | |
|---|---|---|---|
| ed95ce8295 | |||
| 1d301d29b6 | |||
| 6379fac8f6 | |||
| 90f1cf8bdf |
@@ -1,26 +1,38 @@
|
||||
# Scratchbox env-mode example for `scratchbox server --env`.
|
||||
# Set values here and pass this file with `--env-file .env`.
|
||||
#
|
||||
# The complete list of supported SCRATCHBOX_* env vars is the single source of
|
||||
# truth in internal/config/config.go (var envSuffixes). See the comment on
|
||||
# envSuffixes for maintenance rules when adding new vars.
|
||||
#
|
||||
# IMPORTANT:
|
||||
# - Scratchbox stores its key at $SCRATCHBOX_STORAGE_DATA_DIR/metadata.key.
|
||||
# - Keep that data directory persistent across restarts.
|
||||
|
||||
# server
|
||||
SCRATCHBOX_SERVER_LISTEN_ADDR=:8080
|
||||
SCRATCHBOX_SERVER_READ_HEADER_TIMEOUT=5s
|
||||
# Body/response deadlines. 0 = disabled. Raise or set 0 when LIMITS_MAX_UPLOAD_SIZE is multi-GiB.
|
||||
SCRATCHBOX_SERVER_READ_TIMEOUT=30s
|
||||
SCRATCHBOX_SERVER_WRITE_TIMEOUT=30s
|
||||
SCRATCHBOX_SERVER_IDLE_TIMEOUT=120s
|
||||
SCRATCHBOX_SERVER_SHUTDOWN_TIMEOUT=10s
|
||||
SCRATCHBOX_SERVER_MAX_HEADER_BYTES=1048576
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED=true
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_FILE_PATH=
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_SIZE=100MiB
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_BACKUPS=5
|
||||
|
||||
# limits
|
||||
# When raising MAX_UPLOAD_SIZE to multi-GiB, also set READ/WRITE_TIMEOUT high or 0 (see above).
|
||||
SCRATCHBOX_LIMITS_MAX_UPLOAD_SIZE=100MiB
|
||||
SCRATCHBOX_LIMITS_DEFAULT_TTL=15m
|
||||
# In-memory cache for small /api/raw range hits. Larger scratches stream from disk.
|
||||
SCRATCHBOX_LIMITS_RAW_CACHE_MAX_SIZE=200MiB
|
||||
SCRATCHBOX_LIMITS_RAW_CACHE_MAX_ENTRIES=32
|
||||
|
||||
# storage
|
||||
SCRATCHBOX_STORAGE_DATA_DIR=./data
|
||||
SCRATCHBOX_STORAGE_CLEANUP_INTERVAL=1m
|
||||
|
||||
# security
|
||||
@@ -29,12 +41,21 @@ SCRATCHBOX_SECURITY_ALLOWED_IPS=127.0.0.1 # This should be set to your networks
|
||||
SCRATCHBOX_SECURITY_TRUST_PROXY_HEADERS=false
|
||||
# Comma-separated IP/CIDR list.
|
||||
SCRATCHBOX_SECURITY_TRUSTED_PROXY_IPS= # Same as SCRATCHBOX_SECURITY_ALLOWED_IPS just Trusted Proxies you expect clients to come from instead. Must be *immediate* reverse proxies only; avoid 0/0.
|
||||
|
||||
# Per-client token bucket on UI routes (/, /u, /s/{id}).
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_UI_ENABLED=false
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE=360
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_UI_BURST=120
|
||||
|
||||
# Per-client token bucket on API read routes (GET /api/config, GET /api/scratch/{id}, GET /api/raw/{id}).
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_ENABLED=false
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE=300
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_BURST=100
|
||||
|
||||
# Per-client token bucket on API write route (POST /api/scratch).
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_ENABLED=true
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE=30
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_BURST=10
|
||||
|
||||
# Enable Strict-Transport-Security (HSTS) header (recommended for public TLS deploys).
|
||||
SCRATCHBOX_SECURITY_HSTS_ENABLED=true
|
||||
|
||||
+3
-1
@@ -12,4 +12,6 @@
|
||||
/web/ui/node_modules/
|
||||
|
||||
# Frontend build artifacts
|
||||
/web/static/
|
||||
/web/static/
|
||||
# Local/large test fixtures (do not commit)
|
||||
/testdata/*.zip
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Contributing
|
||||
|
||||
## Propose changes
|
||||
|
||||
Open a pull request against `develop`. Keep the default branch for releases and
|
||||
stable tips; land work on `develop` first.
|
||||
|
||||
Point at an existing issue when one fits. Prefer a short issue that states the
|
||||
symptom or request before a large PR.
|
||||
|
||||
## Commits
|
||||
|
||||
Subject form:
|
||||
|
||||
```
|
||||
area: Imperative summary
|
||||
```
|
||||
|
||||
- **Area** is a real package, directory, or subsystem token (`ci:`, `docs:`,
|
||||
Go package name). Not a lone filename.
|
||||
- **Imperative** mood: Fix, Add, Remove — not "Fixed" or "This patch…".
|
||||
- No trailing period. Aim ≤ ~70–75 characters for the whole subject.
|
||||
- Not conventional-commits (`feat:` / `fix:` / `chore:` as types).
|
||||
|
||||
Body explains **why**. Establish the problem, then say what you are doing.
|
||||
One logical change per commit; split fix and cleanup.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Title matches the primary commit subject.
|
||||
|
||||
- **What** changed
|
||||
- **Why** (problem and impact)
|
||||
- **Test** (concrete steps; "CI green" alone is weak)
|
||||
|
||||
## Issues and closing
|
||||
|
||||
Cite leftover issues by **full URL**. Gitea closes issues when `#N` appears in
|
||||
merge text, so do not put `#N` in the merge message unless that issue is actually
|
||||
done. Use `Fixes #N` / `Closes #N` only when the leftover work is finished.
|
||||
|
||||
## License
|
||||
|
||||
License TBD by owner.
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25.4-alpine AS builder
|
||||
FROM golang:1.27.0-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Storage encryption key behavior:
|
||||
- On subsequent startups, Scratchbox reuses the same key file from that data directory.
|
||||
- Keep `storage.data_dir` persistent; losing or replacing `metadata.key` makes existing scratches unreadable.
|
||||
|
||||
Environment variables supported by `scratchbox server --env` (note: when `--env` is used, the variables and their values are printed to stdout on startup for diagnostics, with values of IP allow/trusted lists redacted):
|
||||
Environment variables supported by `scratchbox server --env` (note: when `--env` is used, the variables and their values are printed to stdout on startup for diagnostics, with values of IP allow/trusted lists redacted). The canonical list of supported vars is envSuffixes in internal/config/config.go — see the comment there for how to keep this documentation in sync when adding new configuration.
|
||||
|
||||
- `SCRATCHBOX_SERVER_LISTEN_ADDR`
|
||||
- `SCRATCHBOX_SERVER_READ_HEADER_TIMEOUT`
|
||||
@@ -97,7 +97,7 @@ Environment variables supported by `scratchbox server --env` (note: when `--env`
|
||||
- `SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_BURST`
|
||||
- `SCRATCHBOX_SECURITY_HSTS_ENABLED`
|
||||
|
||||
Example `.env` for Docker:
|
||||
Example `.env` for Docker (must be kept in sync with envSuffixes in internal/config/config.go):
|
||||
|
||||
```dotenv
|
||||
# server
|
||||
@@ -109,6 +109,9 @@ SCRATCHBOX_SERVER_IDLE_TIMEOUT=120s
|
||||
SCRATCHBOX_SERVER_SHUTDOWN_TIMEOUT=10s
|
||||
SCRATCHBOX_SERVER_MAX_HEADER_BYTES=1048576
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED=true
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_FILE_PATH=
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_SIZE=100MiB
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_BACKUPS=5
|
||||
|
||||
# limits
|
||||
SCRATCHBOX_LIMITS_MAX_UPLOAD_SIZE=100MiB
|
||||
@@ -180,11 +183,13 @@ All configuration is YAML.
|
||||
- `read_header_timeout`: maximum time for reading request headers.
|
||||
- Must be `> 0`
|
||||
- Default: `5s`
|
||||
- `read_timeout`: maximum time for reading the full request.
|
||||
- Must be `> 0`
|
||||
- `read_timeout`: maximum time for reading the full request (including body).
|
||||
- Must be `>= 0`. `0` disables the deadline (same as Go `http.Server`).
|
||||
- Raise this (or set `0`) when `limits.max_upload_size` is large or clients are slow: a 30s body timeout cannot complete multi-GiB uploads.
|
||||
- Default: `30s`
|
||||
- `write_timeout`: maximum time allowed for writing the response.
|
||||
- Must be `> 0`
|
||||
- `write_timeout`: maximum time allowed for writing the response (also bounds slow request handling after headers are read).
|
||||
- Must be `>= 0`. `0` disables the deadline.
|
||||
- Raise this (or set `0`) for large uploads/downloads; after request headers are read the write deadline covers the rest of the handler.
|
||||
- Default: `30s`
|
||||
- `idle_timeout`: maximum keep-alive idle time between requests.
|
||||
- Must be `> 0`
|
||||
@@ -213,9 +218,10 @@ All configuration is YAML.
|
||||
- If no unit is provided, value is interpreted as bytes
|
||||
- Examples: `5MB`, `100MiB`, `1GiB`, `1048576`
|
||||
- Default: `100MiB`
|
||||
- `default_ttl`: expiration applied to new scratches.
|
||||
- `default_ttl`: expiration applied to new scratches when `POST /api/scratch` omits `ttl`.
|
||||
- Must be `> 0` and `<= 24h`
|
||||
- Default: `15m`
|
||||
- Optional per-scratch `ttl` is a Go duration from `1m` through `24h`. Multipart uploads use form field `ttl`; raw-body POSTs use query `ttl`. Invalid or out-of-range values return 400. Infinite TTL is not allowed.
|
||||
- `raw_cache_max_size`: max total decompressed bytes cached in memory for `/api/raw` range requests.
|
||||
- Supported units: `B`, `KB`, `MB`, `GB`, `KiB`, `MiB`, `GiB` (case-insensitive)
|
||||
- Must be `> 0`
|
||||
|
||||
@@ -135,6 +135,7 @@ func TestMainProcessEnvSuccess(t *testing.T) {
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_WANT_MAIN_HELPER=1",
|
||||
"MAIN_HELPER_MODE=env-success",
|
||||
// These are from the canonical envSuffixes list (internal/config/config.go)
|
||||
"SCRATCHBOX_SERVER_LISTEN_ADDR=127.0.0.1:0",
|
||||
"SCRATCHBOX_STORAGE_DATA_DIR="+filepath.Join(tmp, "data"),
|
||||
"SCRATCHBOX_SECURITY_ALLOWED_IPS=",
|
||||
|
||||
@@ -34,7 +34,7 @@ func newServerCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
|
||||
cmd.Flags().BoolVar(&useEnv, "env", false, "load config from SCRATCHBOX_* environment variables")
|
||||
cmd.Flags().BoolVar(&useEnv, "env", false, "load config from SCRATCHBOX_* environment variables (see envSuffixes in internal/config/config.go)")
|
||||
cmd.MarkFlagsMutuallyExclusive("config", "env")
|
||||
return cmd
|
||||
}
|
||||
@@ -134,6 +134,8 @@ func run(configPath string, useEnv bool) error {
|
||||
}
|
||||
|
||||
func printEnvStartup() int {
|
||||
// All SCRATCHBOX_* variables are defined by envSuffixes (single source of truth)
|
||||
// in internal/config/config.go. See the comment there for update rules.
|
||||
entries := make([]string, 0)
|
||||
for _, item := range os.Environ() {
|
||||
if strings.HasPrefix(item, "SCRATCHBOX_") {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# This script forces certain SCRATCHBOX_* vars for Docker.
|
||||
# The full list of supported env vars (single source of truth) is in
|
||||
# internal/config/config.go:envSuffixes . Update that list + docs when changing env support.
|
||||
|
||||
# Container-enforced data directory.
|
||||
SCRATCHBOX_STORAGE_DATA_DIR=/data
|
||||
export SCRATCHBOX_STORAGE_DATA_DIR
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module scratchbox
|
||||
|
||||
go 1.25.4
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.10.1
|
||||
|
||||
+76
-12
@@ -44,11 +44,70 @@ const (
|
||||
defaultAPIWriteRequestsPerMinute = 30
|
||||
defaultAPIWriteRateLimitBurst = 10
|
||||
defaultHSTSEnabled = true
|
||||
maxDefaultTTLLimit = 24 * time.Hour
|
||||
minCleanupInterval = 10 * time.Second
|
||||
envPrefix = "SCRATCHBOX_"
|
||||
// MinScratchTTL / MaxScratchTTL bound optional per-scratch TTL on POST /api/scratch
|
||||
// and also cap limits.default_ttl.
|
||||
MinScratchTTL = time.Minute
|
||||
MaxScratchTTL = 24 * time.Hour
|
||||
minCleanupInterval = 10 * time.Second
|
||||
envPrefix = "SCRATCHBOX_"
|
||||
)
|
||||
|
||||
// envSuffixes is the single source of truth for every SCRATCHBOX_* environment
|
||||
// variable suffix supported by applyEnvOverrides (without the "SCRATCHBOX_" prefix).
|
||||
//
|
||||
// Usage sites that hardcode or document these vars MUST have a comment pointing here:
|
||||
// - applyEnvOverrides (this file)
|
||||
// - printEnvStartup + flag docs in cmd/scratchbox/server.go
|
||||
// - docker-entrypoint.sh
|
||||
// - .env.example
|
||||
// - README.md (supported list + example block)
|
||||
// - tests that setenv them (e.g. config_test.go, main_test.go)
|
||||
//
|
||||
// When you add, remove, or rename support in applyEnvOverrides (or the corresponding
|
||||
// struct fields), you MUST:
|
||||
// 1. Update this list.
|
||||
// 2. Add/update the pointer comment at every usage site.
|
||||
// 3. Update .env.example with the new var (plus a sensible default + comment).
|
||||
// 4. Update the "Environment variables supported by..." list in README.md.
|
||||
// 5. Update the example .env block in README.md.
|
||||
//
|
||||
// A test in config_test.go will fail if .env.example or README.md are missing any
|
||||
// entry from this list. This prevents the documentation drift that has happened
|
||||
// in the past when new config fields (e.g. shutdown_timeout, access_log details)
|
||||
// were added.
|
||||
var envSuffixes = []string{
|
||||
"SERVER_LISTEN_ADDR",
|
||||
"SERVER_READ_HEADER_TIMEOUT",
|
||||
"SERVER_READ_TIMEOUT",
|
||||
"SERVER_WRITE_TIMEOUT",
|
||||
"SERVER_IDLE_TIMEOUT",
|
||||
"SERVER_SHUTDOWN_TIMEOUT",
|
||||
"SERVER_MAX_HEADER_BYTES",
|
||||
"SERVER_ACCESS_LOG_ENABLED",
|
||||
"SERVER_ACCESS_LOG_FILE_PATH",
|
||||
"SERVER_ACCESS_LOG_MAX_SIZE",
|
||||
"SERVER_ACCESS_LOG_MAX_BACKUPS",
|
||||
"LIMITS_MAX_UPLOAD_SIZE",
|
||||
"LIMITS_DEFAULT_TTL",
|
||||
"LIMITS_RAW_CACHE_MAX_SIZE",
|
||||
"LIMITS_RAW_CACHE_MAX_ENTRIES",
|
||||
"STORAGE_DATA_DIR",
|
||||
"STORAGE_CLEANUP_INTERVAL",
|
||||
"SECURITY_ALLOWED_IPS",
|
||||
"SECURITY_TRUST_PROXY_HEADERS",
|
||||
"SECURITY_TRUSTED_PROXY_IPS",
|
||||
"SECURITY_RATE_LIMIT_UI_ENABLED",
|
||||
"SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE",
|
||||
"SECURITY_RATE_LIMIT_UI_BURST",
|
||||
"SECURITY_RATE_LIMIT_API_READ_ENABLED",
|
||||
"SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE",
|
||||
"SECURITY_RATE_LIMIT_API_READ_BURST",
|
||||
"SECURITY_RATE_LIMIT_API_WRITE_ENABLED",
|
||||
"SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE",
|
||||
"SECURITY_RATE_LIMIT_API_WRITE_BURST",
|
||||
"SECURITY_HSTS_ENABLED",
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server" comment:"HTTP listener settings."`
|
||||
Limits LimitsConfig `yaml:"limits" comment:"Upload, expiration, and raw cache defaults."`
|
||||
@@ -59,8 +118,8 @@ type Config struct {
|
||||
type ServerConfig struct {
|
||||
ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."`
|
||||
ReadHeaderTimeout string `yaml:"read_header_timeout" comment:"Maximum duration for reading request headers (for slowloris protection). Must be > 0."`
|
||||
ReadTimeout string `yaml:"read_timeout" comment:"Maximum duration for reading the full request, including body. Must be > 0."`
|
||||
WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response. Must be > 0."`
|
||||
ReadTimeout string `yaml:"read_timeout" comment:"Maximum duration for reading the full request, including body. Use 0 to disable (recommended when limits.max_upload_size is multi-GiB or clients are slow). Must be >= 0."`
|
||||
WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response (also bounds slow handlers after headers are read). Use 0 to disable (recommended for large uploads/downloads). Must be >= 0."`
|
||||
IdleTimeout string `yaml:"idle_timeout" comment:"Maximum amount of time to wait for the next request when keep-alives are enabled. Must be > 0."`
|
||||
ShutdownTimeout string `yaml:"shutdown_timeout" comment:"Graceful shutdown timeout for the HTTP server. Must be > 0."`
|
||||
MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."`
|
||||
@@ -82,7 +141,7 @@ type AccessLogConfig struct {
|
||||
|
||||
type LimitsConfig struct {
|
||||
MaxUploadSize string `yaml:"max_upload_size" comment:"Maximum upload request body size. Supports B, KB, MB, GB and KiB, MiB, GiB (case-insensitive). Defaults to bytes when unit is omitted (example: 1048576)."`
|
||||
DefaultTTL string `yaml:"default_ttl" comment:"Default expiration for new scratches. Must be > 0 and <= 24h."`
|
||||
DefaultTTL string `yaml:"default_ttl" comment:"Default expiration for new scratches when POST /api/scratch omits ttl. Must be > 0 and <= 24h."`
|
||||
RawCacheMaxSize string `yaml:"raw_cache_max_size" comment:"Maximum total decompressed bytes cached in memory for /api/raw range requests. Supports B, KB, MB, GB and KiB, MiB, GiB. Set this above limits.max_upload_size based on your expected concurrent large downloads."`
|
||||
RawCacheMaxEntries int `yaml:"raw_cache_max_entries" comment:"Maximum number of decompressed /api/raw entries retained in memory cache. Use this as a secondary cap alongside raw_cache_max_size."`
|
||||
MaxUploadSizeBytes int64 `yaml:"-"`
|
||||
@@ -214,8 +273,9 @@ func (c *Config) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("server.read_timeout invalid: %w", err)
|
||||
}
|
||||
if readTimeout <= 0 {
|
||||
return errors.New("server.read_timeout must be > 0")
|
||||
// 0 means no timeout (matches net/http.Server.ReadTimeout). Negative is invalid.
|
||||
if readTimeout < 0 {
|
||||
return errors.New("server.read_timeout must be >= 0")
|
||||
}
|
||||
c.Server.ReadTimeoutDur = readTimeout
|
||||
|
||||
@@ -223,8 +283,9 @@ func (c *Config) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("server.write_timeout invalid: %w", err)
|
||||
}
|
||||
if writeTimeout <= 0 {
|
||||
return errors.New("server.write_timeout must be > 0")
|
||||
// 0 means no timeout (matches net/http.Server.WriteTimeout). Negative is invalid.
|
||||
if writeTimeout < 0 {
|
||||
return errors.New("server.write_timeout must be >= 0")
|
||||
}
|
||||
c.Server.WriteTimeoutDur = writeTimeout
|
||||
|
||||
@@ -268,8 +329,8 @@ func (c *Config) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("limits.default_ttl invalid: %w", err)
|
||||
}
|
||||
if ttl <= 0 || ttl > maxDefaultTTLLimit {
|
||||
return fmt.Errorf("limits.default_ttl must be in (0, %s]", maxDefaultTTLLimit)
|
||||
if ttl <= 0 || ttl > MaxScratchTTL {
|
||||
return fmt.Errorf("limits.default_ttl must be in (0, %s]", MaxScratchTTL)
|
||||
}
|
||||
c.Limits.DefaultTTLDuration = ttl
|
||||
|
||||
@@ -530,6 +591,9 @@ func decodeEncryptionKey(raw string) ([]byte, error) {
|
||||
return encKey, nil
|
||||
}
|
||||
|
||||
// applyEnvOverrides applies all supported SCRATCHBOX_* vars.
|
||||
// The canonical list of supported suffixes is in envSuffixes (defined earlier in this file).
|
||||
// See the comment on envSuffixes for the full maintenance rules (update list + .env.example + README).
|
||||
func (c *Config) applyEnvOverrides(prefix string) error {
|
||||
if err := applyStringEnv(prefix+"SERVER_LISTEN_ADDR", &c.Server.ListenAddr); err != nil {
|
||||
return err
|
||||
|
||||
@@ -56,6 +56,12 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
|
||||
if cfg.Limits.DefaultTTLDuration != 15*time.Minute {
|
||||
t.Fatalf("DefaultTTLDuration = %s, want 15m", cfg.Limits.DefaultTTLDuration)
|
||||
}
|
||||
if MaxScratchTTL != 24*time.Hour {
|
||||
t.Fatalf("MaxScratchTTL = %s, want 24h", MaxScratchTTL)
|
||||
}
|
||||
if MinScratchTTL != time.Minute {
|
||||
t.Fatalf("MinScratchTTL = %s, want 1m", MinScratchTTL)
|
||||
}
|
||||
if cfg.Limits.RawCacheMaxBytes != 200*1024*1024 {
|
||||
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(200*1024*1024))
|
||||
}
|
||||
@@ -79,6 +85,27 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAllowsZeroReadAndWriteTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaults()
|
||||
cfg.Storage.DataDir = t.TempDir()
|
||||
// 0 disables the deadline (net/http semantics). Needed for multi-GiB uploads
|
||||
// where a fixed 30s body/response timeout cannot work.
|
||||
cfg.Server.ReadTimeout = "0s"
|
||||
cfg.Server.WriteTimeout = "0"
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() returned error: %v", err)
|
||||
}
|
||||
if cfg.Server.ReadTimeoutDur != 0 {
|
||||
t.Fatalf("ReadTimeoutDur = %s, want 0", cfg.Server.ReadTimeoutDur)
|
||||
}
|
||||
if cfg.Server.WriteTimeoutDur != 0 {
|
||||
t.Fatalf("WriteTimeoutDur = %s, want 0", cfg.Server.WriteTimeoutDur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesConfiguredFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -249,6 +276,13 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
|
||||
},
|
||||
wantErr: "server.read_timeout",
|
||||
},
|
||||
{
|
||||
name: "negative read timeout",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Server.ReadTimeout = "-1s"
|
||||
},
|
||||
wantErr: "server.read_timeout",
|
||||
},
|
||||
{
|
||||
name: "invalid write timeout",
|
||||
mutate: func(cfg *Config) {
|
||||
@@ -256,6 +290,13 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
|
||||
},
|
||||
wantErr: "server.write_timeout",
|
||||
},
|
||||
{
|
||||
name: "negative write timeout",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Server.WriteTimeout = "-1s"
|
||||
},
|
||||
wantErr: "server.write_timeout",
|
||||
},
|
||||
{
|
||||
name: "invalid idle timeout",
|
||||
mutate: func(cfg *Config) {
|
||||
@@ -713,3 +754,46 @@ func TestGenerateEncryptionKey(t *testing.T) {
|
||||
t.Fatalf("decoded key length = %d, want 32", len(b1))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvDocumentationIsUpToDate ensures .env.example and the env var
|
||||
// documentation + example in README.md stay in sync with the env vars
|
||||
// actually supported by the code (envSuffixes in config.go).
|
||||
// See the comment on envSuffixes for the list of places that must point back to it.
|
||||
//
|
||||
// This is the guard against the recurring "example env and readme have
|
||||
// become out of sync" problem. When you add a new field that is loaded via
|
||||
// applyEnvOverrides, you must:
|
||||
// - add its suffix to envSuffixes
|
||||
// - add a line (with example value + comment) to .env.example
|
||||
// - add it to the list and the ```dotenv example in README.md
|
||||
//
|
||||
// The test will fail loudly until the docs are updated.
|
||||
func TestEnvDocumentationIsUpToDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check .env.example (at repo root). Note: go test for this package
|
||||
// runs with cwd set to the package directory (internal/config), so we
|
||||
// need to go up two levels.
|
||||
envExample, err := os.ReadFile("../../.env.example")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read .env.example: %v", err)
|
||||
}
|
||||
for _, suffix := range envSuffixes {
|
||||
name := "SCRATCHBOX_" + suffix
|
||||
if !strings.Contains(string(envExample), name) {
|
||||
t.Errorf(".env.example is missing %s (add it with a default/example value and a comment matching the style of the others)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Check README.md env documentation (the supported list and the example block)
|
||||
readme, err := os.ReadFile("../../README.md")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read README.md: %v", err)
|
||||
}
|
||||
for _, suffix := range envSuffixes {
|
||||
name := "SCRATCHBOX_" + suffix
|
||||
if !strings.Contains(string(readme), name) {
|
||||
t.Errorf("README.md env docs (supported list or example .env block) are missing %s; update both the bullet list and the ```dotenv block", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||
"max_upload_size_bytes": s.cfg.Limits.MaxUploadSizeBytes,
|
||||
"upload_allowed": s.isUploadAllowedForRequest(r),
|
||||
"default_ttl": s.cfg.Limits.DefaultTTL,
|
||||
"max_ttl": config.MaxScratchTTL.String(),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
@@ -172,15 +173,22 @@ func (s *Server) scratchPage(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// multipartMaxMemory is how much of a multipart upload may live in RAM before
|
||||
// the remainder spills to temporary files. It must NOT be tied to
|
||||
// limits.max_upload_size: when that limit is multi-GiB, using it as maxMemory
|
||||
// forces entire large files into process memory before storage compression.
|
||||
const multipartMaxMemory = 32 << 20 // 32 MiB
|
||||
|
||||
func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxUploadSizeBytes)
|
||||
|
||||
var reader io.Reader
|
||||
originalName := ""
|
||||
contentType := strings.TrimSpace(r.Header.Get("Content-Type"))
|
||||
multipart := strings.HasPrefix(contentType, "multipart/form-data")
|
||||
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(s.cfg.Limits.MaxUploadSizeBytes); err != nil {
|
||||
if multipart {
|
||||
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
|
||||
s.writeCreateError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -222,7 +230,13 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration)
|
||||
ttl, err := s.resolveTTL(r, multipart)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, ttl)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "request body too large") {
|
||||
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
||||
@@ -315,6 +329,29 @@ func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
w.Header().Set("Content-Type", meta.ContentType)
|
||||
|
||||
// Scratches larger than the raw cache cannot be retained in memory. Stream
|
||||
// them from storage instead of io.ReadAll so multi-GiB downloads do not OOM.
|
||||
// Range requests are only supported via the cache path (ServeContent needs a Seeker).
|
||||
if meta.Size > s.cfg.Limits.RawCacheMaxBytes {
|
||||
file, _, openErr := s.store.Open(meta.ID)
|
||||
if openErr != nil {
|
||||
if errors.Is(openErr, storage.ErrNotFound) {
|
||||
s.rawCache.Delete(id)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
return
|
||||
}
|
||||
http.Error(w, "failed to read scratch", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
w.Header().Set("Accept-Ranges", "none")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", meta.Size))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, file)
|
||||
return
|
||||
}
|
||||
|
||||
content, err, _ := s.rawCache.GetOrLoad(meta.ID, func() ([]byte, error) {
|
||||
file, _, openErr := s.store.Open(meta.ID)
|
||||
if openErr != nil {
|
||||
@@ -335,6 +372,33 @@ func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, meta.ID, meta.CreatedAt, bytes.NewReader(content))
|
||||
}
|
||||
|
||||
// resolveTTL returns the per-scratch TTL for POST /api/scratch.
|
||||
// Multipart uploads read form field "ttl"; raw-body uploads read query "ttl".
|
||||
// Omitted or empty values use limits.default_ttl. Invalid or out-of-range
|
||||
// values (below 1m, above MaxScratchTTL, or non-duration) return an error.
|
||||
// Infinite TTL is not allowed.
|
||||
func (s *Server) resolveTTL(r *http.Request, multipart bool) (time.Duration, error) {
|
||||
raw := ""
|
||||
if multipart {
|
||||
raw = strings.TrimSpace(r.FormValue("ttl"))
|
||||
} else {
|
||||
raw = strings.TrimSpace(r.URL.Query().Get("ttl"))
|
||||
}
|
||||
if raw == "" {
|
||||
return s.cfg.Limits.DefaultTTLDuration, nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
return 0, errInvalidScratchTTL
|
||||
}
|
||||
if parsed < config.MinScratchTTL || parsed > config.MaxScratchTTL {
|
||||
return 0, errInvalidScratchTTL
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
var errInvalidScratchTTL = fmt.Errorf("ttl must be a Go duration between %s and %s", config.MinScratchTTL, config.MaxScratchTTL)
|
||||
|
||||
func (s *Server) writeCreateError(w http.ResponseWriter, err error) {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
|
||||
|
||||
@@ -373,3 +373,49 @@ func min(a, b int) int {
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Scratches larger than limits.raw_cache_max_size must stream from storage
|
||||
// (no full in-memory buffer) so multi-GiB downloads stay viable.
|
||||
func TestRawStreamsWhenLargerThanCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := bytes.Repeat([]byte("Z"), 8*1024) // 8 KiB body
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: int64(len(payload) * 2),
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
rawCacheBytes: 1024, // smaller than payload => stream path
|
||||
})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewReader(payload))
|
||||
createReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
createReq.RemoteAddr = "127.0.0.1:7100"
|
||||
createRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, want 201 body=%q", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
var created map[string]any
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create payload: %v", err)
|
||||
}
|
||||
id, _ := created["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("create payload missing id")
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||
rawReq.RemoteAddr = "127.0.0.1:7101"
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusOK {
|
||||
t.Fatalf("raw status = %d, want 200", rawRec.Code)
|
||||
}
|
||||
if got := rawRec.Header().Get("Accept-Ranges"); got != "none" {
|
||||
t.Fatalf("Accept-Ranges = %q, want none (stream path)", got)
|
||||
}
|
||||
if got := rawRec.Body.Bytes(); !bytes.Equal(got, payload) {
|
||||
t.Fatalf("raw body len=%d, want %d (content mismatch or truncated)", len(got), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type testServerOptions struct {
|
||||
maxUploadBytes int64
|
||||
defaultTTL time.Duration
|
||||
rateLimitEnable bool
|
||||
rawCacheBytes int64 // 0 => default 64MiB
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T, opts testServerOptions) http.Handler {
|
||||
@@ -36,6 +37,11 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
|
||||
rawCacheBytes := opts.rawCacheBytes
|
||||
if rawCacheBytes <= 0 {
|
||||
rawCacheBytes = 64 * 1024 * 1024
|
||||
}
|
||||
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{
|
||||
ListenAddr: ":0",
|
||||
@@ -43,7 +49,7 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
|
||||
Limits: config.LimitsConfig{
|
||||
MaxUploadSizeBytes: opts.maxUploadBytes,
|
||||
DefaultTTLDuration: opts.defaultTTL,
|
||||
RawCacheMaxBytes: 64 * 1024 * 1024,
|
||||
RawCacheMaxBytes: rawCacheBytes,
|
||||
RawCacheMaxEntries: 32,
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateScratchTTLQueryOmitUsesDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
before := time.Now().UTC()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("omit-ttl"))
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
req.RemoteAddr = "127.0.0.1:8101"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, payload["expires_at"].(string))
|
||||
if err != nil {
|
||||
// JSON may marshal as RFC3339
|
||||
expiresAt, err = time.Parse(time.RFC3339, payload["expires_at"].(string))
|
||||
if err != nil {
|
||||
t.Fatalf("parse expires_at %v (%T): %v", payload["expires_at"], payload["expires_at"], err)
|
||||
}
|
||||
}
|
||||
delta := expiresAt.Sub(before)
|
||||
if delta < 14*time.Minute || delta > 16*time.Minute {
|
||||
t.Fatalf("expires delta = %s, want ~15m", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchTTLQueryMinMaxAndInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ttl string
|
||||
wantStatus int
|
||||
wantDelta time.Duration
|
||||
deltaSlack time.Duration
|
||||
}{
|
||||
{name: "min", ttl: "1m", wantStatus: http.StatusCreated, wantDelta: time.Minute, deltaSlack: 15 * time.Second},
|
||||
{name: "max", ttl: "24h", wantStatus: http.StatusCreated, wantDelta: 24 * time.Hour, deltaSlack: time.Minute},
|
||||
{name: "custom", ttl: "2h", wantStatus: http.StatusCreated, wantDelta: 2 * time.Hour, deltaSlack: 30 * time.Second},
|
||||
{name: "below-min", ttl: "30s", wantStatus: http.StatusBadRequest},
|
||||
{name: "above-max", ttl: "25h", wantStatus: http.StatusBadRequest},
|
||||
{name: "invalid", ttl: "not-a-duration", wantStatus: http.StatusBadRequest},
|
||||
{name: "zero", ttl: "0s", wantStatus: http.StatusBadRequest},
|
||||
{name: "negative", ttl: "-5m", wantStatus: http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
before := time.Now().UTC()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch?ttl="+tc.ttl, bytes.NewBufferString("body-"+tc.name))
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
req.RemoteAddr = "127.0.0.1:8200"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d body=%q", rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tc.wantStatus != http.StatusCreated {
|
||||
if !strings.Contains(rec.Body.String(), "ttl") {
|
||||
t.Fatalf("expected ttl error message, got %q", rec.Body.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
expiresRaw, _ := payload["expires_at"].(string)
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, expiresRaw)
|
||||
if err != nil {
|
||||
expiresAt, err = time.Parse(time.RFC3339, expiresRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expires_at %q: %v", expiresRaw, err)
|
||||
}
|
||||
}
|
||||
delta := expiresAt.Sub(before)
|
||||
if delta < tc.wantDelta-tc.deltaSlack || delta > tc.wantDelta+tc.deltaSlack {
|
||||
t.Fatalf("expires delta = %s, want ~%s", delta, tc.wantDelta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchTTLMultipartFormField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file, err := writer.CreateFormFile("file", "ttl.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile: %v", err)
|
||||
}
|
||||
if _, err := file.Write([]byte("multipart-ttl")); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
if err := writer.WriteField("ttl", "5m"); err != nil {
|
||||
t.Fatalf("WriteField ttl: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close writer: %v", err)
|
||||
}
|
||||
|
||||
before := time.Now().UTC()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.RemoteAddr = "127.0.0.1:8301"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
expiresRaw, _ := payload["expires_at"].(string)
|
||||
expiresAt, err := time.Parse(time.RFC3339Nano, expiresRaw)
|
||||
if err != nil {
|
||||
expiresAt, err = time.Parse(time.RFC3339, expiresRaw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse expires_at %q: %v", expiresRaw, err)
|
||||
}
|
||||
}
|
||||
delta := expiresAt.Sub(before)
|
||||
if delta < 4*time.Minute || delta > 6*time.Minute {
|
||||
t.Fatalf("expires delta = %s, want ~5m", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchTTLMultipartInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file, _ := writer.CreateFormFile("file", "bad-ttl.txt")
|
||||
_, _ = file.Write([]byte("x"))
|
||||
_ = writer.WriteField("ttl", "forever")
|
||||
_ = writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.RemoteAddr = "127.0.0.1:8302"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUIConfigExposesMaxTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 2048,
|
||||
defaultTTL: 15 * time.Minute,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
// DefaultTTL string is empty in test helper; set via direct server call already covered.
|
||||
// Hit routed /api/config and ensure max_ttl is present.
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||
req.RemoteAddr = "127.0.0.1:8401"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `"max_ttl"`) {
|
||||
t.Fatalf("expected max_ttl in body: %q", body)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scratchbox/internal/config"
|
||||
"scratchbox/internal/storage"
|
||||
@@ -88,11 +90,76 @@ func TestGetUIConfig(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`) {
|
||||
if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`, `"max_ttl":"`+config.MaxScratchTTL.String()+`"`, "default_ttl") {
|
||||
t.Fatalf("unexpected body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{
|
||||
cfg: config.Config{
|
||||
Limits: config.LimitsConfig{
|
||||
DefaultTTLDuration: 15 * time.Minute,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
formTTL string
|
||||
multipart bool
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "omit raw uses default", url: "/api/scratch", want: 15 * time.Minute},
|
||||
{name: "empty query uses default", url: "/api/scratch?ttl=", want: 15 * time.Minute},
|
||||
{name: "empty form uses default", url: "/api/scratch", multipart: true, formTTL: " ", want: 15 * time.Minute},
|
||||
{name: "query min", url: "/api/scratch?ttl=1m", want: time.Minute},
|
||||
{name: "query max", url: "/api/scratch?ttl=24h", want: config.MaxScratchTTL},
|
||||
{name: "query default value", url: "/api/scratch?ttl=15m", want: 15 * time.Minute},
|
||||
{name: "form min", url: "/api/scratch", multipart: true, formTTL: "1m", want: time.Minute},
|
||||
{name: "form max", url: "/api/scratch", multipart: true, formTTL: "24h", want: config.MaxScratchTTL},
|
||||
{name: "form default value", url: "/api/scratch", multipart: true, formTTL: "15m", want: 15 * time.Minute},
|
||||
{name: "invalid duration", url: "/api/scratch?ttl=nope", wantErr: true},
|
||||
{name: "below min", url: "/api/scratch?ttl=59s", wantErr: true},
|
||||
{name: "above max", url: "/api/scratch?ttl=24h1s", wantErr: true},
|
||||
{name: "zero", url: "/api/scratch?ttl=0", wantErr: true},
|
||||
{name: "negative", url: "/api/scratch?ttl=-1m", wantErr: true},
|
||||
{name: "form invalid", url: "/api/scratch", multipart: true, formTTL: "forever", wantErr: true},
|
||||
{name: "form below min", url: "/api/scratch", multipart: true, formTTL: "30s", wantErr: true},
|
||||
{name: "form above max", url: "/api/scratch", multipart: true, formTTL: "25h", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := httptest.NewRequest(http.MethodPost, tc.url, nil)
|
||||
if tc.multipart {
|
||||
req.Form = url.Values{}
|
||||
if tc.formTTL != "" {
|
||||
req.Form.Set("ttl", tc.formTTL)
|
||||
}
|
||||
}
|
||||
got, err := s.resolveTTL(req, tc.multipart)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("resolveTTL() error = nil, want error (got %s)", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTTL() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("resolveTTL() = %s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUIConfigUploadDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+106
-2
@@ -7,6 +7,8 @@
|
||||
|
||||
let maxUploadSizeBytes = $state(0);
|
||||
let defaultTTL = $state("15m");
|
||||
let maxTTL = $state("24h");
|
||||
let selectedTTL = $state("15m");
|
||||
let uploadAllowed = $state(true);
|
||||
let configError = $state("");
|
||||
|
||||
@@ -29,6 +31,8 @@
|
||||
|
||||
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
|
||||
const defaultTTLDisplay = $derived(formatTTLDisplay(defaultTTL));
|
||||
const maxTTLDisplay = $derived(formatTTLDisplay(maxTTL));
|
||||
const ttlOptions = $derived(buildTTLOptions(defaultTTL, maxTTL));
|
||||
|
||||
const viewPageTitle = $derived(
|
||||
viewMeta?.filename
|
||||
@@ -101,11 +105,15 @@
|
||||
maxUploadSizeBytes = Number.isFinite(max) ? max : 0;
|
||||
uploadAllowed = Boolean(data.upload_allowed);
|
||||
defaultTTL = String(data.default_ttl ?? "").trim() || "15m";
|
||||
maxTTL = String(data.max_ttl ?? "").trim() || "24h";
|
||||
selectedTTL = defaultTTL;
|
||||
} catch (err) {
|
||||
configError = err instanceof Error ? err.message : String(err);
|
||||
maxUploadSizeBytes = 0;
|
||||
uploadAllowed = true;
|
||||
defaultTTL = "15m";
|
||||
maxTTL = "24h";
|
||||
selectedTTL = defaultTTL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +313,85 @@
|
||||
}
|
||||
});
|
||||
|
||||
function parseTTLToSeconds(rawTTL) {
|
||||
const source = String(rawTTL ?? "").trim().toLowerCase();
|
||||
if (!source) {
|
||||
return NaN;
|
||||
}
|
||||
const matches = Array.from(source.matchAll(/(\d+)\s*([hms])/g));
|
||||
if (matches.length === 0) {
|
||||
return NaN;
|
||||
}
|
||||
let total = 0;
|
||||
for (const match of matches) {
|
||||
const value = Number.parseInt(match[1], 10);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
continue;
|
||||
}
|
||||
const unit = match[2];
|
||||
if (unit === "h") {
|
||||
total += value * 3600;
|
||||
} else if (unit === "m") {
|
||||
total += value * 60;
|
||||
} else if (unit === "s") {
|
||||
total += value;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function buildTTLOptions(defaultRaw, maxRaw) {
|
||||
const presets = ["1m", "5m", "15m", "30m", "1h", "6h", "12h", "24h"];
|
||||
const maxSeconds = parseTTLToSeconds(maxRaw);
|
||||
const defaultSeconds = parseTTLToSeconds(defaultRaw);
|
||||
const seen = new Set();
|
||||
const options = [];
|
||||
|
||||
function addOption(value) {
|
||||
const seconds = parseTTLToSeconds(value);
|
||||
if (!Number.isFinite(seconds) || seconds < 60) {
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(maxSeconds) && seconds > maxSeconds) {
|
||||
return;
|
||||
}
|
||||
if (seen.has(seconds)) {
|
||||
return;
|
||||
}
|
||||
seen.add(seconds);
|
||||
options.push({
|
||||
value,
|
||||
label: formatTTLDisplay(value),
|
||||
seconds
|
||||
});
|
||||
}
|
||||
|
||||
for (const preset of presets) {
|
||||
addOption(preset);
|
||||
}
|
||||
addOption(defaultRaw);
|
||||
addOption(maxRaw);
|
||||
|
||||
options.sort((a, b) => a.seconds - b.seconds);
|
||||
|
||||
// Ensure selected/default is present even if parsing failed above.
|
||||
if (defaultRaw && !options.some((opt) => opt.value === defaultRaw)) {
|
||||
options.unshift({
|
||||
value: defaultRaw,
|
||||
label: formatTTLDisplay(defaultRaw),
|
||||
seconds: Number.isFinite(defaultSeconds) ? defaultSeconds : 0
|
||||
});
|
||||
}
|
||||
|
||||
return options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label:
|
||||
opt.value === defaultRaw
|
||||
? `${opt.label} (default)`
|
||||
: opt.label
|
||||
}));
|
||||
}
|
||||
|
||||
function expiresInText(expiresAt, now) {
|
||||
const expiry = Date.parse(String(expiresAt ?? ""));
|
||||
if (!Number.isFinite(expiry)) {
|
||||
@@ -393,6 +480,8 @@
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", selectedFile);
|
||||
const ttlValue = String(selectedTTL ?? "").trim() || defaultTTL;
|
||||
formData.append("ttl", ttlValue);
|
||||
const response = await fetch("/api/scratch", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
@@ -509,7 +598,7 @@
|
||||
{:else if routeMode === "upload"}
|
||||
<section class="panel">
|
||||
<p class="helper">Size limit: {maxUploadSizeDisplay}</p>
|
||||
<p class="helper">Expiration: {defaultTTLDisplay}</p>
|
||||
<p class="helper">Default expiration: {defaultTTLDisplay} (max {maxTTLDisplay})</p>
|
||||
{#if configError}
|
||||
<p class="helper warning">Could not load UI config: {configError}</p>
|
||||
{/if}
|
||||
@@ -530,6 +619,20 @@
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="ttl-panel" class="input-panel">
|
||||
<label for="ttl">Expiration</label>
|
||||
<select
|
||||
id="ttl"
|
||||
name="ttl"
|
||||
bind:value={selectedTTL}
|
||||
disabled={loading}
|
||||
>
|
||||
{#each ttlOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<button class="link-button nav-button action-button submit-button" type="submit" disabled={loading}>
|
||||
{#if loading}Creating...{:else}Create scratch{/if}
|
||||
</button>
|
||||
@@ -657,7 +760,8 @@
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
input[type="file"],
|
||||
select {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
background: #121826;
|
||||
|
||||
Reference in New Issue
Block a user