Lots more changes

This commit is contained in:
2026-06-01 01:29:47 -05:00
parent 8bec7f0dda
commit 050ec138f3
20 changed files with 1773 additions and 689 deletions
+1
View File
@@ -3,6 +3,7 @@
# Run Artifacts
/*.yaml
/config.key
/data/
# Coverage outputs
+8 -4
View File
@@ -4,13 +4,13 @@ BIN_DIR := ./bin
BIN := $(BIN_DIR)/$(APP_NAME)
CONFIG ?= config.yaml
.PHONY: help run dev generate-config build test test-race fmt clean
.PHONY: help run dev 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 config - Write default config to $(CONFIG) and generate sibling config.key if missing"
@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"
@@ -22,8 +22,12 @@ run: dev
dev: build
$(BIN) server --config $(CONFIG)
generate-config:
go run $(CMD_DIR) generate-config > $(CONFIG)
config:
mkdir -p "$(dir $(CONFIG))"
go run $(CMD_DIR) generate-config > "$(CONFIG)"
@if [ ! -f "$(dir $(CONFIG))config.key" ]; then \
go run $(CMD_DIR) generate-key > "$(dir $(CONFIG))config.key"; \
fi
build: clean
npm --prefix ./web/ui run build
+39 -14
View File
@@ -2,17 +2,20 @@
Scratchbox is a minimal self-hosted scratch service written in Go. It is intentionally unauthenticated and optimized for temporary sharing of text or small files with an expiration time.
Each upload creates exactly one scratch. For multi-file sharing, bundle files into an archive (for example `.zip` or `.tar.gz`) and upload that single archive file.
Scratch content is stored on disk gzip-compressed transparently to reduce filesystem usage; API/UI reads return original uncompressed content.
For multi-file sharing, bundle files into an archive (for example `.zip` or `.tar.gz`) before upload.
Scratch content is gzip-compressed and AES-encrypted on disk transparently; API/UI reads return original uncompressed content.
## Quick Start
1. Generate defaults directly:
- `go run ./cmd/scratchbox generate-config > config.yaml`
2. Adjust settings for your environment.
3. Start the server:
2. Generate an encryption key file next to the config:
- `go run ./cmd/scratchbox generate-key > config.key`
- or: `openssl rand -base64 32 > config.key`
3. Adjust settings for your environment.
4. Start the server:
- `go run ./cmd/scratchbox server --config config.yaml`
4. Open:
5. Open:
- `http://127.0.0.1:8080/`
Without `--config`, built-in defaults are used.
@@ -43,6 +46,16 @@ All configuration is YAML.
- `max_header_bytes`: max HTTP header size in bytes.
- Must be `> 0`
- Default: `1048576` (1 MiB)
- `access_log`: structured request logs.
- `enabled`: enable HTTP access logs
- Default: `true`
- `file_path`: optional log file path
- When set, logs are tee'd to both stdout and the file
- `max_size`: rotation threshold for `file_path`
- Supported units: `B`, `KB`, `MB`, `GB`, `KiB`, `MiB`, `GiB`
- Default: `100MiB`
- `max_backups`: number of rotated files to retain
- Default: `5`
### `limits`
@@ -53,7 +66,7 @@ All configuration is YAML.
- Default: `100MiB`
- `default_ttl`: expiration applied to new scratches.
- Must be `> 0` and `<= 24h`
- Default: `1h`
- Default: `15m`
- `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`
@@ -70,6 +83,12 @@ All configuration is YAML.
- `cleanup_interval`: background interval for deleting expired content.
- Must be at least `10s`
- Default: `1m`
- `encryption_key_file`: path to a file containing a base64-encoded 32-byte key used for at-rest encryption.
- Relative paths are resolved from the config file directory
- Default: `config.key` (expected alongside `config.yaml`)
- Keep this key stable across restarts
- Changing the key makes existing scratch files unreadable
- `generate-key` emits a fresh random key each run
### `security`
@@ -84,14 +103,18 @@ All configuration is YAML.
- `trusted_proxy_ips`: list of reverse-proxy IPs/CIDRs permitted to supply forwarded headers.
- Required when `trust_proxy_headers: true`
- Examples: `127.0.0.1`, `10.0.0.0/8`
- `rate_limit.enabled`: enable per-IP token-bucket rate limiting on write and scratch-read routes (`POST /api/scratch`, `GET /api/scratch/{id}`, `GET /s/{id}`, `GET /api/raw/{id}`).
- Default: `true`
- `rate_limit.requests_per_minute`: steady refill rate per client IP.
- Must be `> 0`
- Default: `30`
- `rate_limit.burst`: immediate burst capacity per client IP.
- Must be `> 0`
- Default: `10`
- `rate_limit_ui`: per-IP token-bucket for UI routes (`GET /`, `GET /u`, `GET /s/{id}`).
- `enabled` default: `false`
- `requests_per_minute` must be `> 0` (default `360`)
- `burst` must be `> 0` (default `120`)
- `rate_limit_api_read`: per-IP token-bucket for API read routes (`GET /api/config`, `GET /api/scratch/{id}`, `GET /api/raw/{id}`).
- `enabled` default: `false`
- `requests_per_minute` must be `> 0` (default `300`)
- `burst` must be `> 0` (default `100`)
- `rate_limit_api_write`: per-IP token-bucket for API write route (`POST /api/scratch`).
- `enabled` default: `true`
- `requests_per_minute` must be `> 0` (default `30`)
- `burst` must be `> 0` (default `10`)
## Security Posture
@@ -129,3 +152,5 @@ Scratchbox storage is designed for a single process instance per `storage.data_d
- Do not run multiple scratchbox processes against the same data directory.
- Metadata/index writes are process-local and are not coordinated with cross-process locking.
- Scratch payload files and metadata index are encrypted at rest.
- Scratch IDs are derived from the SHA-256 of the stored blob (encrypted+gzip payload on disk).
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"sync"
"scratchbox/internal/config"
)
func newAccessLogWriter(cfg config.Config) (io.Writer, func() error, error) {
if !cfg.Server.AccessLog.Enabled {
return io.Discard, func() error { return nil }, nil
}
if cfg.Server.AccessLog.FilePath == "" {
return os.Stdout, func() error { return nil }, nil
}
writer, err := newRollingFileWriter(
cfg.Server.AccessLog.FilePath,
cfg.Server.AccessLog.MaxSizeBytes,
cfg.Server.AccessLog.MaxBackups,
)
if err != nil {
return nil, nil, err
}
return io.MultiWriter(os.Stdout, writer), writer.Close, nil
}
type rollingFileWriter struct {
mu sync.Mutex
path string
maxSize int64
maxBackups int
file *os.File
size int64
}
func newRollingFileWriter(path string, maxSize int64, maxBackups int) (*rollingFileWriter, error) {
if maxSize <= 0 {
return nil, fmt.Errorf("max size must be > 0")
}
if maxBackups <= 0 {
return nil, fmt.Errorf("max backups must be > 0")
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create log directory: %w", err)
}
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return nil, fmt.Errorf("stat log file: %w", err)
}
return &rollingFileWriter{
path: path,
maxSize: maxSize,
maxBackups: maxBackups,
file: f,
size: info.Size(),
}, nil
}
func (w *rollingFileWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.size > 0 && w.size+int64(len(p)) > w.maxSize {
if err := w.rotateLocked(); err != nil {
return 0, err
}
}
n, err := w.file.Write(p)
w.size += int64(n)
return n, err
}
func (w *rollingFileWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rollingFileWriter) rotateLocked() error {
if err := w.file.Close(); err != nil {
return fmt.Errorf("close before rotate: %w", err)
}
for i := w.maxBackups - 1; i >= 1; i-- {
from := rotatedLogPath(w.path, i)
to := rotatedLogPath(w.path, i+1)
if _, err := os.Stat(from); err != nil {
if os.IsNotExist(err) {
continue
}
return fmt.Errorf("stat rotated file %s: %w", from, err)
}
_ = os.Remove(to)
if err := os.Rename(from, to); err != nil {
return fmt.Errorf("rotate backup %s -> %s: %w", from, to, err)
}
}
firstBackup := rotatedLogPath(w.path, 1)
_ = os.Remove(firstBackup)
if _, err := os.Stat(w.path); err == nil {
if err := os.Rename(w.path, firstBackup); err != nil {
return fmt.Errorf("rotate current log to %s: %w", firstBackup, err)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("stat current log file: %w", err)
}
f, err := os.OpenFile(w.path, os.O_TRUNC|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open rotated log file: %w", err)
}
w.file = f
w.size = 0
return nil
}
func rotatedLogPath(path string, index int) string {
return fmt.Sprintf("%s.%d", path, index)
}
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"bytes"
"os"
"path/filepath"
"testing"
"scratchbox/internal/config"
)
func TestNewAccessLogWriterDisabled(t *testing.T) {
t.Parallel()
cfg := config.Default()
cfg.Server.AccessLog.Enabled = false
writer, closeFn, err := newAccessLogWriter(cfg)
if err != nil {
t.Fatalf("newAccessLogWriter() error = %v", err)
}
if writer == nil {
t.Fatalf("newAccessLogWriter() writer = nil")
}
if err := closeFn(); err != nil {
t.Fatalf("closeFn() error = %v", err)
}
}
func TestRollingFileWriterRotatesAndKeepsBackups(t *testing.T) {
t.Parallel()
logPath := filepath.Join(t.TempDir(), "access.log")
w, err := newRollingFileWriter(logPath, 10, 2)
if err != nil {
t.Fatalf("newRollingFileWriter() error = %v", err)
}
defer w.Close()
_, _ = w.Write([]byte("12345"))
_, _ = w.Write([]byte("67890"))
_, _ = w.Write([]byte("abc"))
_, _ = w.Write([]byte("defghijkl"))
if err := w.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
current, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile(current) error = %v", err)
}
backup1, err := os.ReadFile(logPath + ".1")
if err != nil {
t.Fatalf("ReadFile(backup1) error = %v", err)
}
backup2, err := os.ReadFile(logPath + ".2")
if err != nil {
t.Fatalf("ReadFile(backup2) error = %v", err)
}
if !bytes.Equal(current, []byte("defghijkl")) {
t.Fatalf("current log = %q, want %q", string(current), "defghijkl")
}
if !bytes.Equal(backup1, []byte("abc")) {
t.Fatalf("backup1 log = %q, want %q", string(backup1), "abc")
}
if !bytes.Equal(backup2, []byte("1234567890")) {
t.Fatalf("backup2 log = %q, want %q", string(backup2), "1234567890")
}
}
+32
View File
@@ -0,0 +1,32 @@
package main
import (
"fmt"
"github.com/spf13/cobra"
"scratchbox/internal/config"
)
func newGenerateKeyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "generate-key",
Short: "Print a random base64 encryption key to stdout",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return writeEncryptionKey(cmd)
},
}
return cmd
}
func writeEncryptionKey(cmd *cobra.Command) error {
key, err := config.GenerateEncryptionKey()
if err != nil {
return fmt.Errorf("generate encryption key: %w", err)
}
if _, err := fmt.Fprintln(cmd.OutOrStdout(), key); err != nil {
return fmt.Errorf("write encryption key: %w", err)
}
return nil
}
+1
View File
@@ -27,5 +27,6 @@ func newRootCmd() *cobra.Command {
}
cmd.AddCommand(newServerCmd())
cmd.AddCommand(newGenerateConfigCmd())
cmd.AddCommand(newGenerateKeyCmd())
return cmd
}
+104 -7
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"log"
@@ -31,6 +32,18 @@ func repoRoot(t *testing.T) string {
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
func writeConfigKeyFile(t *testing.T, configPath string) {
t.Helper()
keyPath := filepath.Join(filepath.Dir(configPath), "config.key")
key, err := config.GenerateEncryptionKey()
if err != nil {
t.Fatalf("GenerateEncryptionKey() error = %v", err)
}
if err := os.WriteFile(keyPath, []byte(key+"\n"), 0o600); err != nil {
t.Fatalf("WriteFile(config.key) error = %v", err)
}
}
func TestMainProcessSuccess(t *testing.T) {
t.Parallel()
@@ -48,7 +61,15 @@ storage:
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit:
rate_limit_ui:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_read:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_write:
enabled: true
requests_per_minute: 10
burst: 1
@@ -56,6 +77,7 @@ security:
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
writeConfigKeyFile(t, cfgPath)
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
@@ -104,7 +126,15 @@ storage:
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit:
rate_limit_ui:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_read:
enabled: true
requests_per_minute: 10
burst: 1
rate_limit_api_write:
enabled: true
requests_per_minute: 10
burst: 1
@@ -112,6 +142,7 @@ security:
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
writeConfigKeyFile(t, cfgPath)
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
cmd.Dir = repoRoot(t)
@@ -198,8 +229,26 @@ func TestGenerateConfigCommandWritesDefaultYAML(t *testing.T) {
if cfg.Storage.DataDir != "./data" {
t.Fatalf("storage.data_dir = %q, want %q", cfg.Storage.DataDir, "./data")
}
if cfg.Security.RateLimit.RequestsPerMinute != 30 {
t.Fatalf("security.rate_limit.requests_per_minute = %d, want %d", cfg.Security.RateLimit.RequestsPerMinute, 30)
if cfg.Storage.EncryptionKeyFile != "config.key" {
t.Fatalf("storage.encryption_key_file = %q, want %q", cfg.Storage.EncryptionKeyFile, "config.key")
}
if cfg.Security.RateLimitUI.RequestsPerMinute != 360 {
t.Fatalf("security.rate_limit_ui.requests_per_minute = %d, want %d", cfg.Security.RateLimitUI.RequestsPerMinute, 360)
}
if cfg.Security.RateLimitUI.Enabled {
t.Fatalf("security.rate_limit_ui.enabled = %v, want false", cfg.Security.RateLimitUI.Enabled)
}
if cfg.Security.RateLimitAPIRead.RequestsPerMinute != 300 {
t.Fatalf("security.rate_limit_api_read.requests_per_minute = %d, want %d", cfg.Security.RateLimitAPIRead.RequestsPerMinute, 300)
}
if cfg.Security.RateLimitAPIRead.Enabled {
t.Fatalf("security.rate_limit_api_read.enabled = %v, want false", cfg.Security.RateLimitAPIRead.Enabled)
}
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 30 {
t.Fatalf("security.rate_limit_api_write.requests_per_minute = %d, want %d", cfg.Security.RateLimitAPIWrite.RequestsPerMinute, 30)
}
if !cfg.Security.RateLimitAPIWrite.Enabled {
t.Fatalf("security.rate_limit_api_write.enabled = %v, want true", cfg.Security.RateLimitAPIWrite.Enabled)
}
}
@@ -216,6 +265,45 @@ func TestGenerateConfigCommandRejectsUnexpectedArgs(t *testing.T) {
}
}
func TestGenerateKeyCommandWritesKey(t *testing.T) {
t.Parallel()
cmd := newRootCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"generate-key"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
raw := strings.TrimSpace(out.String())
if raw == "" {
t.Fatalf("generate-key output is empty")
}
keyBytes, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
t.Fatalf("generate-key output is not valid base64: %v", err)
}
if len(keyBytes) != 32 {
t.Fatalf("decoded key length = %d, want 32", len(keyBytes))
}
}
func TestGenerateKeyCommandRejectsUnexpectedArgs(t *testing.T) {
t.Parallel()
cmd := newRootCmd()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"generate-key", "extra"})
if err := cmd.Execute(); err == nil {
t.Fatalf("expected error for unexpected args")
}
}
func TestLiveServerEndToEndHTTP(t *testing.T) {
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.yaml")
@@ -231,7 +319,15 @@ storage:
security:
allowed_ips: []
trust_proxy_headers: false
rate_limit:
rate_limit_ui:
enabled: true
requests_per_minute: 30
burst: 10
rate_limit_api_read:
enabled: true
requests_per_minute: 30
burst: 10
rate_limit_api_write:
enabled: true
requests_per_minute: 30
burst: 10
@@ -239,13 +335,14 @@ security:
if err := os.WriteFile(cfgPath, []byte(cfgRaw), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
writeConfigKeyFile(t, cfgPath)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatalf("config.Load() error = %v", err)
}
logger := log.New(io.Discard, "", 0)
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir)
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir, cfg.Storage.EncryptionKeyBytes)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -261,7 +358,7 @@ security:
_ = os.Chdir(wd)
}()
srv, err := httpapi.NewServer(cfg, store, logger)
srv, err := httpapi.NewServer(cfg, store, logger, logger)
if err != nil {
t.Fatalf("httpapi.NewServer() error = %v", err)
}
+19 -2
View File
@@ -40,13 +40,30 @@ func run(configPath string) error {
}
logger := log.New(os.Stdout, "[scratchbox] ", log.LstdFlags|log.LUTC)
accessWriter, closeAccessWriter, err := newAccessLogWriter(cfg)
if err != nil {
return fmt.Errorf("access log init failed: %w", err)
}
defer func() {
if closeErr := closeAccessWriter(); closeErr != nil {
logger.Printf("access log writer close failed: %v", closeErr)
}
}()
var accessLogger *log.Logger
if cfg.Server.AccessLog.Enabled {
accessLogger = log.New(accessWriter, "[access] ", log.LstdFlags|log.LUTC)
}
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir)
store, err := storage.NewFilesystemStoreWithDefaultTTL(
cfg.Storage.DataDir,
cfg.Limits.DefaultTTLDuration,
cfg.Storage.EncryptionKeyBytes,
)
if err != nil {
return fmt.Errorf("storage init failed: %w", err)
}
server, err := httpapi.NewServer(cfg, store, logger)
server, err := httpapi.NewServer(cfg, store, logger, accessLogger)
if err != nil {
return fmt.Errorf("http init failed: %w", err)
}
+5 -3
View File
@@ -12,10 +12,12 @@ import (
"scratchbox/internal/storage"
)
var testStorageKey = []byte("0123456789abcdef0123456789abcdef")
func TestNewWorker(t *testing.T) {
t.Parallel()
store, err := storage.NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := storage.NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -29,7 +31,7 @@ func TestNewWorker(t *testing.T) {
func TestWorkerStartRemovesExpiredAndLogs(t *testing.T) {
t.Parallel()
store, err := storage.NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := storage.NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -53,7 +55,7 @@ func TestWorkerStartRemovesExpiredAndLogs(t *testing.T) {
func TestWorkerStartWithNoExpiredEntries(t *testing.T) {
t.Parallel()
store, err := storage.NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := storage.NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
+163 -38
View File
@@ -1,6 +1,8 @@
package config
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/netip"
@@ -14,23 +16,34 @@ import (
)
const (
defaultListenAddr = ":8080"
defaultReadHeaderTimeout = "5s"
defaultReadTimeout = "30s"
defaultWriteTimeout = "30s"
defaultIdleTimeout = "120s"
defaultMaxHeaderBytes = 1 << 20
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
defaultListenAddr = ":8080"
defaultReadHeaderTimeout = "5s"
defaultReadTimeout = "30s"
defaultWriteTimeout = "30s"
defaultIdleTimeout = "120s"
defaultMaxHeaderBytes = 1 << 20
defaultAccessLogEnabled = true
defaultAccessLogMaxSize = "100MiB"
defaultAccessLogMaxBackups = 5
defaultMaxUploadSize = "100MiB"
defaultTTL = "15m"
defaultRawCacheMaxSize = "200MiB"
defaultRawCacheMaxEntries = 32
defaultDataDir = "./data"
defaultCleanupInterval = "1m"
defaultStorageEncryptionKey = "vVPlqMfKa8OtD3x0RKp4rVCJ4QoScf5mWdgfg4f+5GU="
defaultEncryptionKeyFile = "config.key"
defaultUIRateLimitEnabled = false
defaultAPIReadRateLimitEnabled = false
defaultAPIWriteRateLimitEnabled = true
defaultUIRequestsPerMinute = 360
defaultUIRateLimitBurst = 120
defaultAPIReadRequestsPerMinute = 300
defaultAPIReadRateLimitBurst = 100
defaultAPIWriteRequestsPerMinute = 30
defaultAPIWriteRateLimitBurst = 10
maxDefaultTTLLimit = 24 * time.Hour
minCleanupInterval = 10 * time.Second
)
type Config struct {
@@ -41,16 +54,25 @@ 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."`
IdleTimeout string `yaml:"idle_timeout" comment:"Maximum amount of time to wait for the next request when keep-alives are enabled. Must be > 0."`
MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."`
ReadHeaderTimeoutDur time.Duration `yaml:"-"`
ReadTimeoutDur time.Duration `yaml:"-"`
WriteTimeoutDur time.Duration `yaml:"-"`
IdleTimeoutDur time.Duration `yaml:"-"`
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."`
IdleTimeout string `yaml:"idle_timeout" comment:"Maximum amount of time to wait for the next request when keep-alives are enabled. Must be > 0."`
MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."`
AccessLog AccessLogConfig `yaml:"access_log" comment:"Access log settings for all HTTP requests."`
ReadHeaderTimeoutDur time.Duration `yaml:"-"`
ReadTimeoutDur time.Duration `yaml:"-"`
WriteTimeoutDur time.Duration `yaml:"-"`
IdleTimeoutDur time.Duration `yaml:"-"`
}
type AccessLogConfig struct {
Enabled bool `yaml:"enabled" comment:"Enable HTTP access logs."`
FilePath string `yaml:"file_path" comment:"Optional file path for access logs. When set, logs are tee'd to stdout and this file."`
MaxSize string `yaml:"max_size" comment:"Maximum size for access log file before rotation. Supports B, KB, MB, GB and KiB, MiB, GiB."`
MaxBackups int `yaml:"max_backups" comment:"Number of rotated access log files to keep."`
MaxSizeBytes int64 `yaml:"-"`
}
type LimitsConfig struct {
@@ -66,14 +88,18 @@ type LimitsConfig struct {
type StorageConfig struct {
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)."`
EncryptionKeyFile string `yaml:"encryption_key_file" comment:"Path to a file containing a base64-encoded 32-byte AES key for at-rest encryption. Relative paths are resolved from the config file directory."`
CleanupIntervalParsed time.Duration `yaml:"-"`
EncryptionKeyBytes []byte `yaml:"-"`
}
type SecurityConfig struct {
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 only when request source IP matches security.trusted_proxy_ips."`
TrustedProxyIPs []string `yaml:"trusted_proxy_ips" comment:"IPs/CIDRs of reverse proxies allowed to supply forwarded client IP headers. Required when trust_proxy_headers is true."`
RateLimit RateLimitConfig `yaml:"rate_limit" comment:"Per-client token bucket on write and scratch read routes."`
RateLimitUI RateLimitConfig `yaml:"rate_limit_ui" comment:"Per-client token bucket on UI routes (/, /u, /s/{id})."`
RateLimitAPIRead RateLimitConfig `yaml:"rate_limit_api_read" comment:"Per-client token bucket on API read routes (GET /api/config, GET /api/scratch/{id}, GET /api/raw/{id})."`
RateLimitAPIWrite RateLimitConfig `yaml:"rate_limit_api_write" comment:"Per-client token bucket on API write route (POST /api/scratch)."`
AllowedPrefixes []netip.Prefix `yaml:"-"`
TrustedProxyPrefixes []netip.Prefix `yaml:"-"`
}
@@ -95,6 +121,9 @@ func Load(path string) (Config, error) {
return Config{}, fmt.Errorf("parse config yaml: %w", err)
}
}
if err := cfg.loadEncryptionKey(path); err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
@@ -115,6 +144,11 @@ func defaults() Config {
WriteTimeout: defaultWriteTimeout,
IdleTimeout: defaultIdleTimeout,
MaxHeaderBytes: defaultMaxHeaderBytes,
AccessLog: AccessLogConfig{
Enabled: defaultAccessLogEnabled,
MaxSize: defaultAccessLogMaxSize,
MaxBackups: defaultAccessLogMaxBackups,
},
},
Limits: LimitsConfig{
MaxUploadSize: defaultMaxUploadSize,
@@ -123,17 +157,29 @@ func defaults() Config {
RawCacheMaxEntries: defaultRawCacheMaxEntries,
},
Storage: StorageConfig{
DataDir: defaultDataDir,
CleanupInterval: defaultCleanupInterval,
DataDir: defaultDataDir,
CleanupInterval: defaultCleanupInterval,
EncryptionKeyFile: defaultEncryptionKeyFile,
EncryptionKeyBytes: mustDecodeDefaultEncryptionKey(),
},
Security: SecurityConfig{
AllowedIPs: []string{
"127.0.0.1",
},
RateLimit: RateLimitConfig{
Enabled: defaultRateLimitEnabled,
RequestsPerMinute: defaultRequestsPerMinute,
Burst: defaultRateLimitBurst,
RateLimitUI: RateLimitConfig{
Enabled: defaultUIRateLimitEnabled,
RequestsPerMinute: defaultUIRequestsPerMinute,
Burst: defaultUIRateLimitBurst,
},
RateLimitAPIRead: RateLimitConfig{
Enabled: defaultAPIReadRateLimitEnabled,
RequestsPerMinute: defaultAPIReadRequestsPerMinute,
Burst: defaultAPIReadRateLimitBurst,
},
RateLimitAPIWrite: RateLimitConfig{
Enabled: defaultAPIWriteRateLimitEnabled,
RequestsPerMinute: defaultAPIWriteRequestsPerMinute,
Burst: defaultAPIWriteRateLimitBurst,
},
},
}
@@ -182,6 +228,14 @@ func (c *Config) Validate() error {
if c.Server.MaxHeaderBytes <= 0 {
return errors.New("server.max_header_bytes must be > 0")
}
accessLogMaxSizeBytes, err := parseHumanSize(c.Server.AccessLog.MaxSize)
if err != nil {
return fmt.Errorf("server.access_log.max_size invalid: %w", err)
}
c.Server.AccessLog.MaxSizeBytes = accessLogMaxSizeBytes
if c.Server.AccessLog.MaxBackups <= 0 {
return errors.New("server.access_log.max_backups must be > 0")
}
sizeBytes, err := parseHumanSize(c.Limits.MaxUploadSize)
if err != nil {
@@ -222,6 +276,12 @@ func (c *Config) Validate() error {
if err := ensureWritableDir(c.Storage.DataDir); err != nil {
return fmt.Errorf("storage.data_dir not writable: %w", err)
}
if strings.TrimSpace(c.Storage.EncryptionKeyFile) == "" {
return errors.New("storage.encryption_key_file is required")
}
if len(c.Storage.EncryptionKeyBytes) != 32 {
return fmt.Errorf("storage.encryption_key_file must decode to 32 bytes, got %d", len(c.Storage.EncryptionKeyBytes))
}
prefixes, err := parseAllowedPrefixes(c.Security.AllowedIPs)
if err != nil {
@@ -237,11 +297,23 @@ func (c *Config) Validate() error {
return errors.New("security.trusted_proxy_ips is required when security.trust_proxy_headers is true")
}
if c.Security.RateLimit.RequestsPerMinute <= 0 {
return errors.New("security.rate_limit.requests_per_minute must be > 0")
if c.Security.RateLimitUI.RequestsPerMinute <= 0 {
return errors.New("security.rate_limit_ui.requests_per_minute must be > 0")
}
if c.Security.RateLimit.Burst <= 0 {
return errors.New("security.rate_limit.burst must be > 0")
if c.Security.RateLimitUI.Burst <= 0 {
return errors.New("security.rate_limit_ui.burst must be > 0")
}
if c.Security.RateLimitAPIRead.RequestsPerMinute <= 0 {
return errors.New("security.rate_limit_api_read.requests_per_minute must be > 0")
}
if c.Security.RateLimitAPIRead.Burst <= 0 {
return errors.New("security.rate_limit_api_read.burst must be > 0")
}
if c.Security.RateLimitAPIWrite.RequestsPerMinute <= 0 {
return errors.New("security.rate_limit_api_write.requests_per_minute must be > 0")
}
if c.Security.RateLimitAPIWrite.Burst <= 0 {
return errors.New("security.rate_limit_api_write.burst must be > 0")
}
return nil
@@ -338,3 +410,56 @@ func parseHumanSize(raw string) (int64, error) {
}
return size, nil
}
func GenerateEncryptionKey() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate encryption key bytes: %w", err)
}
return base64.StdEncoding.EncodeToString(raw), nil
}
func (c *Config) loadEncryptionKey(configPath string) error {
if configPath == "" {
return nil
}
keyPath := strings.TrimSpace(c.Storage.EncryptionKeyFile)
if keyPath == "" {
return errors.New("storage.encryption_key_file is required")
}
if !filepath.IsAbs(keyPath) {
keyPath = filepath.Join(filepath.Dir(configPath), keyPath)
}
keyRaw, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("read storage.encryption_key_file %q: %w", keyPath, err)
}
encKey, err := decodeEncryptionKey(strings.TrimSpace(string(keyRaw)))
if err != nil {
return fmt.Errorf("storage.encryption_key_file invalid: %w", err)
}
c.Storage.EncryptionKeyBytes = encKey
return nil
}
func decodeEncryptionKey(raw string) ([]byte, error) {
if raw == "" {
return nil, errors.New("key is empty")
}
encKey, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("invalid base64: %w", err)
}
if len(encKey) != 32 {
return nil, fmt.Errorf("must decode to 32 bytes, got %d", len(encKey))
}
return encKey, nil
}
func mustDecodeDefaultEncryptionKey() []byte {
encKey, err := decodeEncryptionKey(defaultStorageEncryptionKey)
if err != nil {
panic(fmt.Sprintf("invalid built-in encryption key: %v", err))
}
return encKey
}
+165 -11
View File
@@ -1,6 +1,7 @@
package config
import (
"encoding/base64"
"net/netip"
"os"
"path/filepath"
@@ -37,8 +38,17 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
if cfg.Server.MaxHeaderBytes != 1<<20 {
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 1<<20)
}
if cfg.Limits.DefaultTTLDuration != time.Hour {
t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration)
if !cfg.Server.AccessLog.Enabled {
t.Fatalf("AccessLog.Enabled = %v, want true", cfg.Server.AccessLog.Enabled)
}
if cfg.Server.AccessLog.MaxSizeBytes != 100*1024*1024 {
t.Fatalf("AccessLog.MaxSizeBytes = %d, want %d", cfg.Server.AccessLog.MaxSizeBytes, int64(100*1024*1024))
}
if cfg.Server.AccessLog.MaxBackups != 5 {
t.Fatalf("AccessLog.MaxBackups = %d, want 5", cfg.Server.AccessLog.MaxBackups)
}
if cfg.Limits.DefaultTTLDuration != 15*time.Minute {
t.Fatalf("DefaultTTLDuration = %s, want 15m", cfg.Limits.DefaultTTLDuration)
}
if cfg.Limits.RawCacheMaxBytes != 200*1024*1024 {
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(200*1024*1024))
@@ -49,6 +59,9 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
if cfg.Storage.CleanupIntervalParsed != time.Minute {
t.Fatalf("CleanupIntervalParsed = %s, want 1m", cfg.Storage.CleanupIntervalParsed)
}
if len(cfg.Storage.EncryptionKeyBytes) != 32 {
t.Fatalf("EncryptionKeyBytes length = %d, want 32", len(cfg.Storage.EncryptionKeyBytes))
}
if len(cfg.Security.AllowedPrefixes) != 1 {
t.Fatalf("AllowedPrefixes len = %d, want 1", len(cfg.Security.AllowedPrefixes))
}
@@ -63,6 +76,7 @@ func TestLoadParsesConfiguredFields(t *testing.T) {
tmp := t.TempDir()
dataDir := filepath.Join(tmp, "store")
configPath := filepath.Join(tmp, "config.yaml")
keyPath := filepath.Join(tmp, "scratchbox.key")
content := `
server:
listen_addr: "127.0.0.1:9090"
@@ -71,6 +85,11 @@ server:
write_timeout: "35s"
idle_timeout: "90s"
max_header_bytes: 2097152
access_log:
enabled: true
file_path: "` + filepath.Join(tmp, "access.log") + `"
max_size: "2MiB"
max_backups: 7
limits:
max_upload_size: "2MiB"
default_ttl: "2h"
@@ -79,6 +98,7 @@ limits:
storage:
data_dir: "` + dataDir + `"
cleanup_interval: "45s"
encryption_key_file: "` + filepath.Base(keyPath) + `"
security:
allowed_ips:
- "127.0.0.1"
@@ -86,7 +106,15 @@ security:
trust_proxy_headers: true
trusted_proxy_ips:
- "10.0.0.0/8"
rate_limit:
rate_limit_ui:
enabled: true
requests_per_minute: 12
burst: 4
rate_limit_api_read:
enabled: true
requests_per_minute: 12
burst: 4
rate_limit_api_write:
enabled: true
requests_per_minute: 12
burst: 4
@@ -94,6 +122,9 @@ security:
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := os.WriteFile(keyPath, []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"), 0o600); err != nil {
t.Fatalf("WriteFile(key) error = %v", err)
}
cfg, err := Load(configPath)
if err != nil {
@@ -118,6 +149,15 @@ security:
if cfg.Server.MaxHeaderBytes != 2*1024*1024 {
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 2*1024*1024)
}
if !cfg.Server.AccessLog.Enabled {
t.Fatalf("AccessLog.Enabled = false, want true")
}
if cfg.Server.AccessLog.MaxSizeBytes != 2*1024*1024 {
t.Fatalf("AccessLog.MaxSizeBytes = %d, want %d", cfg.Server.AccessLog.MaxSizeBytes, int64(2*1024*1024))
}
if cfg.Server.AccessLog.MaxBackups != 7 {
t.Fatalf("AccessLog.MaxBackups = %d, want 7", cfg.Server.AccessLog.MaxBackups)
}
if cfg.Limits.MaxUploadSizeBytes != 2*1024*1024 {
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(2*1024*1024))
}
@@ -133,11 +173,23 @@ security:
if cfg.Storage.CleanupIntervalParsed != 45*time.Second {
t.Fatalf("CleanupIntervalParsed = %s, want 45s", cfg.Storage.CleanupIntervalParsed)
}
if len(cfg.Storage.EncryptionKeyBytes) != 32 {
t.Fatalf("EncryptionKeyBytes length = %d, want 32", len(cfg.Storage.EncryptionKeyBytes))
}
if cfg.Storage.EncryptionKeyBytes[0] != 0 {
t.Fatalf("EncryptionKeyBytes[0] = %d, want 0", cfg.Storage.EncryptionKeyBytes[0])
}
if !cfg.Security.TrustProxyHeaders {
t.Fatalf("TrustProxyHeaders = false, want true")
}
if cfg.Security.RateLimit.RequestsPerMinute != 12 || cfg.Security.RateLimit.Burst != 4 {
t.Fatalf("unexpected rate_limit values: %+v", cfg.Security.RateLimit)
if cfg.Security.RateLimitUI.RequestsPerMinute != 12 || cfg.Security.RateLimitUI.Burst != 4 {
t.Fatalf("unexpected rate_limit_ui values: %+v", cfg.Security.RateLimitUI)
}
if cfg.Security.RateLimitAPIRead.RequestsPerMinute != 12 || cfg.Security.RateLimitAPIRead.Burst != 4 {
t.Fatalf("unexpected rate_limit_api_read values: %+v", cfg.Security.RateLimitAPIRead)
}
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 12 || cfg.Security.RateLimitAPIWrite.Burst != 4 {
t.Fatalf("unexpected rate_limit_api_write values: %+v", cfg.Security.RateLimitAPIWrite)
}
if len(cfg.Security.AllowedPrefixes) != 2 {
t.Fatalf("AllowedPrefixes len = %d, want 2", len(cfg.Security.AllowedPrefixes))
@@ -204,6 +256,20 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
},
wantErr: "server.max_header_bytes",
},
{
name: "invalid access log max size",
mutate: func(cfg *Config) {
cfg.Server.AccessLog.MaxSize = "oops"
},
wantErr: "server.access_log.max_size",
},
{
name: "invalid access log max backups",
mutate: func(cfg *Config) {
cfg.Server.AccessLog.MaxBackups = 0
},
wantErr: "server.access_log.max_backups",
},
{
name: "invalid ttl parse",
mutate: func(cfg *Config) {
@@ -255,11 +321,25 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
wantErr: "security.trusted_proxy_ips is required",
},
{
name: "invalid rate limit burst",
name: "invalid ui rate limit burst",
mutate: func(cfg *Config) {
cfg.Security.RateLimit.Burst = 0
cfg.Security.RateLimitUI.Burst = 0
},
wantErr: "security.rate_limit.burst",
wantErr: "security.rate_limit_ui.burst",
},
{
name: "invalid api read rate limit burst",
mutate: func(cfg *Config) {
cfg.Security.RateLimitAPIRead.Burst = 0
},
wantErr: "security.rate_limit_api_read.burst",
},
{
name: "invalid api write rate limit burst",
mutate: func(cfg *Config) {
cfg.Security.RateLimitAPIWrite.Burst = 0
},
wantErr: "security.rate_limit_api_write.burst",
},
{
name: "data dir required",
@@ -283,11 +363,39 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
wantErr: "storage.cleanup_interval",
},
{
name: "invalid rate limit rpm",
name: "encryption key file required",
mutate: func(cfg *Config) {
cfg.Security.RateLimit.RequestsPerMinute = 0
cfg.Storage.EncryptionKeyFile = " "
},
wantErr: "security.rate_limit.requests_per_minute",
wantErr: "storage.encryption_key_file is required",
},
{
name: "encryption key bytes invalid length",
mutate: func(cfg *Config) {
cfg.Storage.EncryptionKeyBytes = []byte("short")
},
wantErr: "storage.encryption_key_file must decode to 32 bytes",
},
{
name: "invalid ui rate limit rpm",
mutate: func(cfg *Config) {
cfg.Security.RateLimitUI.RequestsPerMinute = 0
},
wantErr: "security.rate_limit_ui.requests_per_minute",
},
{
name: "invalid api read rate limit rpm",
mutate: func(cfg *Config) {
cfg.Security.RateLimitAPIRead.RequestsPerMinute = 0
},
wantErr: "security.rate_limit_api_read.requests_per_minute",
},
{
name: "invalid api write rate limit rpm",
mutate: func(cfg *Config) {
cfg.Security.RateLimitAPIWrite.RequestsPerMinute = 0
},
wantErr: "security.rate_limit_api_write.requests_per_minute",
},
}
@@ -328,6 +436,29 @@ func TestLoadErrors(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "parse config yaml") {
t.Fatalf("Load() error = %v, want yaml parse error", err)
}
validConfigPath := filepath.Join(tmp, "valid.yaml")
validConfig := `
storage:
data_dir: "` + filepath.Join(tmp, "store") + `"
cleanup_interval: "10s"
`
if err := os.WriteFile(validConfigPath, []byte(validConfig), 0o644); err != nil {
t.Fatalf("WriteFile(valid config) error = %v", err)
}
_, err = Load(validConfigPath)
if err == nil || !strings.Contains(err.Error(), "read storage.encryption_key_file") {
t.Fatalf("Load() error = %v, want missing key file error", err)
}
keyPath := filepath.Join(tmp, "config.key")
if err := os.WriteFile(keyPath, []byte("not-base64"), 0o600); err != nil {
t.Fatalf("WriteFile(invalid key) error = %v", err)
}
_, err = Load(validConfigPath)
if err == nil || !strings.Contains(err.Error(), "storage.encryption_key_file invalid: invalid base64") {
t.Fatalf("Load() error = %v, want invalid key base64 error", err)
}
}
func TestParseAllowedPrefixesTrimsAndParses(t *testing.T) {
@@ -385,3 +516,26 @@ func TestParseHumanSizeVariants(t *testing.T) {
})
}
}
func TestGenerateEncryptionKey(t *testing.T) {
t.Parallel()
k1, err := GenerateEncryptionKey()
if err != nil {
t.Fatalf("GenerateEncryptionKey() error = %v", err)
}
k2, err := GenerateEncryptionKey()
if err != nil {
t.Fatalf("GenerateEncryptionKey() second error = %v", err)
}
if k1 == k2 {
t.Fatalf("GenerateEncryptionKey() produced duplicate keys")
}
b1, err := base64.StdEncoding.DecodeString(k1)
if err != nil {
t.Fatalf("DecodeString(k1) error = %v", err)
}
if len(b1) != 32 {
t.Fatalf("decoded key length = %d, want 32", len(b1))
}
}
+44 -26
View File
@@ -18,46 +18,63 @@ import (
)
type Server struct {
cfg config.Config
store *storage.FilesystemStore
logger *log.Logger
rawCache *rawContentCache
cfg config.Config
store *storage.FilesystemStore
logger *log.Logger
accessLogger *log.Logger
rawCache *rawContentCache
}
func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger) (*Server, error) {
func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger, accessLogger *log.Logger) (*Server, error) {
return &Server{
cfg: cfg,
store: store,
logger: logger,
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
cfg: cfg,
store: store,
logger: logger,
accessLogger: accessLogger,
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
}, nil
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /{$}", http.HandlerFunc(s.serveSPA))
mux.Handle("GET /u", http.HandlerFunc(s.serveSPA))
mux.Handle("GET /api/config", http.HandlerFunc(s.getUIConfig))
uiMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
apiReadMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
apiWriteMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
if s.cfg.Security.RateLimitUI.Enabled {
uiLimiter := NewRateLimiter(s.cfg.Security.RateLimitUI.RequestsPerMinute, s.cfg.Security.RateLimitUI.Burst)
uiMiddlewares = append(uiMiddlewares, RateLimitMiddleware(uiLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
}
if s.cfg.Security.RateLimitAPIRead.Enabled {
apiReadLimiter := NewRateLimiter(s.cfg.Security.RateLimitAPIRead.RequestsPerMinute, s.cfg.Security.RateLimitAPIRead.Burst)
apiReadMiddlewares = append(apiReadMiddlewares, RateLimitMiddleware(apiReadLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
}
if s.cfg.Security.RateLimitAPIWrite.Enabled {
apiWriteLimiter := NewRateLimiter(s.cfg.Security.RateLimitAPIWrite.RequestsPerMinute, s.cfg.Security.RateLimitAPIWrite.Burst)
apiWriteMiddlewares = append(apiWriteMiddlewares, RateLimitMiddleware(apiWriteLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
}
mux.Handle("GET /{$}", Chain(http.HandlerFunc(s.serveSPA), uiMiddlewares...))
mux.Handle("GET /u", Chain(http.HandlerFunc(s.serveSPA), uiMiddlewares...))
mux.Handle("GET /s/{id}", Chain(http.HandlerFunc(s.scratchPage), uiMiddlewares...))
mux.Handle("GET /api/config", Chain(http.HandlerFunc(s.getUIConfig), apiReadMiddlewares...))
mux.Handle("GET /api/scratch/{id}", Chain(http.HandlerFunc(s.getScratch), apiReadMiddlewares...))
mux.Handle("GET /api/raw/{id}", Chain(http.HandlerFunc(s.rawScratch), apiReadMiddlewares...))
writeHandler := http.HandlerFunc(s.createScratch)
middlewares := make([]func(http.Handler) http.Handler, 0, 2)
middlewares = append(middlewares, AllowlistMiddleware(s.cfg.Security.AllowedPrefixes, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes, s.logger))
readMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
if s.cfg.Security.RateLimit.Enabled {
writeLimiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst)
readLimiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst)
middlewares = append(middlewares, RateLimitMiddleware(writeLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
readMiddlewares = append(readMiddlewares, RateLimitMiddleware(readLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
}
mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...))
mux.Handle("GET /api/scratch/{id}", Chain(http.HandlerFunc(s.getScratch), readMiddlewares...))
mux.Handle("GET /s/{id}", Chain(http.HandlerFunc(s.scratchPage), readMiddlewares...))
mux.Handle("GET /api/raw/{id}", Chain(http.HandlerFunc(s.rawScratch), readMiddlewares...))
writeMiddlewares := make([]func(http.Handler) http.Handler, 0, 2)
writeMiddlewares = append(writeMiddlewares, AllowlistMiddleware(s.cfg.Security.AllowedPrefixes, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes, s.logger))
writeMiddlewares = append(writeMiddlewares, apiWriteMiddlewares...)
mux.Handle("POST /api/scratch", Chain(writeHandler, writeMiddlewares...))
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
return AccessLogMiddleware(
s.accessLogger,
s.cfg.Security.TrustProxyHeaders,
s.cfg.Security.TrustedProxyPrefixes,
)(mux)
}
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
@@ -68,6 +85,7 @@ 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),
"default_ttl": s.cfg.Limits.DefaultTTL,
}
writeJSON(w, http.StatusOK, payload)
}
+13 -3
View File
@@ -162,7 +162,7 @@ func TestReadRoutesAreRateLimitedWhenEnabled(t *testing.T) {
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:9111"
createReq.RemoteAddr = "127.0.0.2:9111"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
@@ -1041,7 +1041,7 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
t.Helper()
dataDir := filepath.Join(t.TempDir(), "data")
store, err := storage.NewFilesystemStore(dataDir)
store, err := storage.NewFilesystemStore(dataDir, testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -1060,7 +1060,17 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
DataDir: dataDir,
},
Security: config.SecurityConfig{
RateLimit: config.RateLimitConfig{
RateLimitUI: config.RateLimitConfig{
Enabled: opts.rateLimitEnable,
RequestsPerMinute: 120,
Burst: 1,
},
RateLimitAPIRead: config.RateLimitConfig{
Enabled: opts.rateLimitEnable,
RequestsPerMinute: 120,
Burst: 1,
},
RateLimitAPIWrite: config.RateLimitConfig{
Enabled: opts.rateLimitEnable,
RequestsPerMinute: 120,
Burst: 1,
+12 -6
View File
@@ -15,6 +15,8 @@ import (
"scratchbox/internal/storage"
)
var testStorageKey = []byte("0123456789abcdef0123456789abcdef")
func TestMultipartFileCount(t *testing.T) {
t.Parallel()
@@ -49,18 +51,20 @@ func TestWriteCreateErrorBranches(t *testing.T) {
func TestNewServerInitializes(t *testing.T) {
tmp := t.TempDir()
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"), testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
cfg := config.Config{
Limits: config.LimitsConfig{MaxUploadSizeBytes: 1024},
Security: config.SecurityConfig{
RateLimit: config.RateLimitConfig{Enabled: false},
RateLimitUI: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIRead: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIWrite: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
},
}
srv, err := NewServer(cfg, store, log.New(io.Discard, "", 0))
srv, err := NewServer(cfg, store, log.New(io.Discard, "", 0), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}
@@ -118,17 +122,19 @@ func TestGetUIConfigUploadDenied(t *testing.T) {
func TestWriteCreateErrorBranchesWithConfig(t *testing.T) {
tmp := t.TempDir()
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"), testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
cfg := config.Config{
Limits: config.LimitsConfig{MaxUploadSizeBytes: 1024},
Security: config.SecurityConfig{
RateLimit: config.RateLimitConfig{Enabled: false},
RateLimitUI: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIRead: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
RateLimitAPIWrite: config.RateLimitConfig{Enabled: false, RequestsPerMinute: 30, Burst: 10},
},
}
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0)); err != nil {
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0), log.New(io.Discard, "", 0)); err != nil {
t.Fatalf("NewServer() error = %v", err)
}
}
+67
View File
@@ -107,6 +107,53 @@ func RateLimitMiddleware(limiter *RateLimiter, trustProxyHeaders bool, trustedPr
}
}
func AccessLogMiddleware(logger *log.Logger, trustProxyHeaders bool, trustedProxies []netip.Prefix) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if logger == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !shouldLogAccessRequest(r.Method, r.URL.Path) {
next.ServeHTTP(w, r)
return
}
start := time.Now()
recorder := &accessLogResponseWriter{ResponseWriter: w}
next.ServeHTTP(recorder, r)
status := recorder.status
if status == 0 {
status = http.StatusOK
}
clientIP := "-"
if ip, ok := extractClientIP(r, trustProxyHeaders, trustedProxies); ok {
clientIP = ip.String()
}
logger.Printf(
"method=%s path=%s status=%d bytes=%d duration_ms=%d ip=%s ua=%q",
r.Method,
r.URL.RequestURI(),
status,
recorder.bytes,
time.Since(start).Milliseconds(),
clientIP,
r.UserAgent(),
)
})
}
}
func shouldLogAccessRequest(method string, path string) bool {
if method == http.MethodPost && path == "/api/scratch" {
return true
}
if method == http.MethodGet && strings.HasPrefix(path, "/api/raw/") && len(path) > len("/api/raw/") {
return true
}
return false
}
func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
wrapped := handler
for i := len(middlewares) - 1; i >= 0; i-- {
@@ -115,6 +162,26 @@ func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler)
return wrapped
}
type accessLogResponseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *accessLogResponseWriter) WriteHeader(statusCode int) {
w.status = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *accessLogResponseWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(p)
w.bytes += n
return n, err
}
func extractClientIP(r *http.Request, trustProxyHeaders bool, trustedProxies []netip.Prefix) (netip.Addr, bool) {
remoteIP, ok := remoteIPFromRequest(r)
if !ok {
+93
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"bytes"
"encoding/json"
"io"
"log"
@@ -228,3 +229,95 @@ func TestExtractClientIPIgnoresHeadersFromUntrustedProxy(t *testing.T) {
t.Fatalf("extractClientIP() = (%v,%v), want (203.0.113.10,true)", ip, ok)
}
}
func TestAccessLogMiddlewareWritesStructuredLog(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
handler := AccessLogMiddleware(logger, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte("ok"))
}))
req := httptest.NewRequest(http.MethodPost, "/api/scratch?ttl=1h", nil)
req.RemoteAddr = "198.51.100.12:4444"
req.Header.Set("User-Agent", "middleware-test")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
got := buf.String()
if !strings.Contains(got, "method=POST") {
t.Fatalf("missing method field in log: %q", got)
}
if !strings.Contains(got, "path=/api/scratch?ttl=1h") {
t.Fatalf("missing path field in log: %q", got)
}
if !strings.Contains(got, "status=201") {
t.Fatalf("missing status field in log: %q", got)
}
if !strings.Contains(got, "bytes=2") {
t.Fatalf("missing bytes field in log: %q", got)
}
if !strings.Contains(got, "ip=198.51.100.12") {
t.Fatalf("missing ip field in log: %q", got)
}
}
func TestAccessLogMiddlewareSkipsConfigAndStaticPaths(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
handler := AccessLogMiddleware(logger, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
reqConfig := httptest.NewRequest(http.MethodGet, "/api/config", nil)
reqConfig.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqConfig)
reqStatic := httptest.NewRequest(http.MethodGet, "/static/app.js", nil)
reqStatic.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqStatic)
reqAsset := httptest.NewRequest(http.MethodGet, "/assets/logo.svg", nil)
reqAsset.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqAsset)
reqScratchView := httptest.NewRequest(http.MethodGet, "/s/abc123", nil)
reqScratchView.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqScratchView)
reqUpload := httptest.NewRequest(http.MethodGet, "/u", nil)
reqUpload.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), reqUpload)
if buf.Len() != 0 {
t.Fatalf("expected no access logs for skipped paths, got %q", buf.String())
}
}
func TestAccessLogMiddlewareOnlyAllowsConfiguredRoutes(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
handler := AccessLogMiddleware(logger, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
disallowed := httptest.NewRequest(http.MethodGet, "/api/scratch/some-id", nil)
disallowed.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), disallowed)
if buf.Len() != 0 {
t.Fatalf("expected no access log for disallowed route, got %q", buf.String())
}
allowed := httptest.NewRequest(http.MethodGet, "/api/raw/some-id", nil)
allowed.RemoteAddr = "198.51.100.12:4444"
handler.ServeHTTP(httptest.NewRecorder(), allowed)
if !strings.Contains(buf.String(), "method=GET path=/api/raw/some-id") {
t.Fatalf("expected access log for allowed route, got %q", buf.String())
}
}
+388 -134
View File
@@ -3,7 +3,11 @@ package storage
import (
"compress/gzip"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
@@ -17,14 +21,16 @@ import (
)
const (
indexFilename = "metadata.json"
filesDirName = "scratches"
idBytes = 12
maxCreateIDAttempts = 100
indexFilename = "metadata"
filesDirName = "scratches"
encryptedChunkSize = 64 * 1024
)
var ErrNotFound = errors.New("scratch not found")
var encryptedFileMagic = [4]byte{'S', 'B', 'X', '1'}
var encryptedIndexMagic = [4]byte{'S', 'M', 'D', '1'}
type Metadata struct {
ID string `json:"id"`
FilePath string `json:"file_path"`
@@ -35,35 +41,63 @@ type Metadata struct {
Size int64 `json:"size"`
}
type FilesystemStore struct {
dataDir string
filesDir string
index string
idGenerator func() (string, error)
mu sync.RWMutex
metadata map[string]Metadata
reservedIDs map[string]struct{}
type indexDocument struct {
DefaultTTL string `json:"default_ttl,omitempty"`
Scratches map[string]Metadata `json:"scratches"`
}
func NewFilesystemStore(dataDir string) (*FilesystemStore, error) {
type FilesystemStore struct {
dataDir string
filesDir string
index string
defaultTTL time.Duration
fileCipher cipher.AEAD
mu sync.RWMutex
metadata map[string]Metadata
}
func NewFilesystemStore(dataDir string, encryptionKey []byte) (*FilesystemStore, error) {
return newFilesystemStore(dataDir, 0, encryptionKey)
}
func NewFilesystemStoreWithDefaultTTL(dataDir string, defaultTTL time.Duration, encryptionKey []byte) (*FilesystemStore, error) {
return newFilesystemStore(dataDir, defaultTTL, encryptionKey)
}
func newFilesystemStore(dataDir string, defaultTTL time.Duration, encryptionKey []byte) (*FilesystemStore, error) {
filesDir := filepath.Join(dataDir, filesDirName)
if err := os.MkdirAll(filesDir, 0o755); err != nil {
return nil, fmt.Errorf("create files dir: %w", err)
}
if len(encryptionKey) != 32 {
return nil, fmt.Errorf("storage encryption key must be 32 bytes, got %d", len(encryptionKey))
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, fmt.Errorf("init storage cipher: %w", err)
}
fileCipher, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("init storage aead: %w", err)
}
store := &FilesystemStore{
dataDir: dataDir,
filesDir: filesDir,
index: filepath.Join(dataDir, indexFilename),
idGenerator: generateID,
metadata: map[string]Metadata{},
reservedIDs: map[string]struct{}{},
dataDir: dataDir,
filesDir: filesDir,
index: filepath.Join(dataDir, indexFilename),
fileCipher: fileCipher,
metadata: map[string]Metadata{},
}
if err := store.loadIndex(); err != nil {
return nil, err
}
if err := store.reconcileOnStartup(time.Now()); err != nil {
now := time.Now().UTC()
if err := store.applyDefaultTTLOnStartup(defaultTTL); err != nil {
return nil, err
}
if err := store.reconcileOnStartup(now); err != nil {
return nil, err
}
return store, nil
@@ -74,107 +108,84 @@ func (s *FilesystemStore) Create(ctx context.Context, body io.Reader, contentTyp
}
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)
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)
}
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()
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
tempFile, err := os.CreateTemp(s.filesDir, "scratch.tmp-*")
if err != nil {
return Metadata{}, fmt.Errorf("create temp file: %w", err)
}
return Metadata{}, fmt.Errorf("failed to allocate unique scratch id after %d attempts", maxCreateIDAttempts)
blobHash := sha256.New()
blobSink := io.MultiWriter(tempFile, blobHash)
scratchWriter := io.Writer(blobSink)
encryptedWriter, err := newEncryptedFileWriter(blobSink, s.fileCipher)
if err != nil {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
return Metadata{}, fmt.Errorf("init encrypted scratch writer: %w", err)
}
scratchWriter = encryptedWriter
gzipWriter := gzip.NewWriter(scratchWriter)
size, copyErr := io.Copy(gzipWriter, body)
gzipCloseErr := gzipWriter.Close()
encryptCloseErr := encryptedWriter.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 encryptCloseErr != nil {
_ = os.Remove(tempFile.Name())
return Metadata{}, fmt.Errorf("close encrypted writer: %w", encryptCloseErr)
}
if closeErr != nil {
_ = os.Remove(tempFile.Name())
return Metadata{}, fmt.Errorf("close temp file: %w", closeErr)
}
id := hex.EncodeToString(blobHash.Sum(nil))
finalPath := filepath.Join(s.filesDir, id)
if _, statErr := os.Stat(finalPath); statErr == nil {
_ = os.Remove(tempFile.Name())
return Metadata{}, errors.New("scratch id collision for content hash")
} else if !errors.Is(statErr, os.ErrNotExist) {
_ = os.Remove(tempFile.Name())
return Metadata{}, fmt.Errorf("stat candidate scratch path: %w", statErr)
}
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,
OriginalName: normalizeOriginalName(originalName),
Size: size,
}
s.mu.Lock()
if _, exists := s.metadata[id]; exists {
s.mu.Unlock()
_ = os.Remove(finalPath)
return Metadata{}, errors.New("scratch id collision in metadata index")
}
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
}
func normalizeOriginalName(originalName string) string {
@@ -212,7 +223,12 @@ func (s *FilesystemStore) Open(id string) (io.ReadCloser, Metadata, error) {
}
return nil, Metadata{}, fmt.Errorf("open scratch file: %w", err)
}
gzipReader, err := gzip.NewReader(file)
decryptedReader, decryptErr := newEncryptedFileReader(file, s.fileCipher)
if decryptErr != nil {
_ = file.Close()
return nil, Metadata{}, fmt.Errorf("create encrypted reader: %w", decryptErr)
}
gzipReader, err := gzip.NewReader(decryptedReader)
if err != nil {
_ = file.Close()
return nil, Metadata{}, fmt.Errorf("create gzip reader: %w", err)
@@ -286,22 +302,46 @@ func (s *FilesystemStore) loadIndex() error {
}
return fmt.Errorf("read metadata index: %w", err)
}
plaintext, err := decryptIndexPayload(b, s.fileCipher)
if err != nil {
return fmt.Errorf("decrypt metadata index: %w", err)
}
loaded := map[string]Metadata{}
if err := json.Unmarshal(b, &loaded); err != nil {
doc := indexDocument{}
if err := json.Unmarshal(plaintext, &doc); err != nil {
return fmt.Errorf("decode metadata index: %w", err)
}
s.metadata = loaded
if doc.Scratches == nil {
doc.Scratches = map[string]Metadata{}
}
s.metadata = doc.Scratches
if ttlRaw := strings.TrimSpace(doc.DefaultTTL); ttlRaw != "" {
ttl, parseErr := time.ParseDuration(ttlRaw)
if parseErr != nil {
return fmt.Errorf("decode metadata index default ttl: %w", parseErr)
}
s.defaultTTL = ttl
}
return nil
}
func (s *FilesystemStore) persistLocked() error {
tmp := s.index + ".tmp"
payload, err := json.MarshalIndent(s.metadata, "", " ")
doc := indexDocument{
Scratches: s.metadata,
}
if s.defaultTTL > 0 {
doc.DefaultTTL = s.defaultTTL.String()
}
payload, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return fmt.Errorf("encode metadata index: %w", err)
}
if err := os.WriteFile(tmp, payload, 0o644); err != nil {
encryptedPayload, err := encryptIndexPayload(payload, s.fileCipher)
if err != nil {
return fmt.Errorf("encrypt metadata index: %w", err)
}
if err := os.WriteFile(tmp, encryptedPayload, 0o644); err != nil {
return fmt.Errorf("write metadata temp file: %w", err)
}
if err := os.Rename(tmp, s.index); err != nil {
@@ -310,6 +350,28 @@ func (s *FilesystemStore) persistLocked() error {
return nil
}
func (s *FilesystemStore) applyDefaultTTLOnStartup(defaultTTL time.Duration) error {
if defaultTTL <= 0 {
return nil
}
if s.defaultTTL <= 0 {
s.defaultTTL = defaultTTL
return s.persistLocked()
}
if s.defaultTTL == defaultTTL {
return nil
}
delta := defaultTTL - s.defaultTTL
for id, meta := range s.metadata {
meta.ExpiresAt = meta.ExpiresAt.Add(delta)
s.metadata[id] = meta
}
s.defaultTTL = defaultTTL
return s.persistLocked()
}
func (s *FilesystemStore) reconcileOnStartup(now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -337,14 +399,6 @@ func (s *FilesystemStore) reconcileOnStartup(now time.Time) error {
return s.persistLocked()
}
func generateID() (string, error) {
b := make([]byte, idBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
type combinedReadCloser struct {
reader io.Reader
closers []io.Closer
@@ -363,3 +417,203 @@ func (c *combinedReadCloser) Close() error {
}
return firstErr
}
type encryptedFileWriter struct {
dst io.Writer
aead cipher.AEAD
noncePrefix [4]byte
counter uint64
pending []byte
}
func newEncryptedFileWriter(dst io.Writer, aead cipher.AEAD) (*encryptedFileWriter, error) {
writer := &encryptedFileWriter{
dst: dst,
aead: aead,
pending: make([]byte, 0, encryptedChunkSize),
}
if _, err := rand.Read(writer.noncePrefix[:]); err != nil {
return nil, fmt.Errorf("generate nonce prefix: %w", err)
}
header := make([]byte, 0, len(encryptedFileMagic)+len(writer.noncePrefix))
header = append(header, encryptedFileMagic[:]...)
header = append(header, writer.noncePrefix[:]...)
if _, err := writer.dst.Write(header); err != nil {
return nil, fmt.Errorf("write encrypted file header: %w", err)
}
return writer, nil
}
func (w *encryptedFileWriter) Write(p []byte) (int, error) {
written := len(p)
for len(p) > 0 {
space := encryptedChunkSize - len(w.pending)
if space > len(p) {
space = len(p)
}
w.pending = append(w.pending, p[:space]...)
p = p[space:]
if len(w.pending) == encryptedChunkSize {
if err := w.flushChunk(w.pending); err != nil {
return written - len(p), err
}
w.pending = w.pending[:0]
}
}
return written, nil
}
func (w *encryptedFileWriter) Close() error {
if len(w.pending) == 0 {
return nil
}
if err := w.flushChunk(w.pending); err != nil {
return err
}
w.pending = w.pending[:0]
return nil
}
func (w *encryptedFileWriter) flushChunk(plaintext []byte) error {
nonce := make([]byte, w.aead.NonceSize())
copy(nonce, w.noncePrefix[:])
binary.BigEndian.PutUint64(nonce[len(w.noncePrefix):], w.counter)
ciphertext := w.aead.Seal(nil, nonce, plaintext, nil)
var sizeBuf [4]byte
binary.BigEndian.PutUint32(sizeBuf[:], uint32(len(ciphertext)))
if _, err := w.dst.Write(sizeBuf[:]); err != nil {
return fmt.Errorf("write encrypted chunk size: %w", err)
}
if _, err := w.dst.Write(ciphertext); err != nil {
return fmt.Errorf("write encrypted chunk payload: %w", err)
}
w.counter++
return nil
}
type encryptedFileReader struct {
src io.Reader
aead cipher.AEAD
noncePrefix [4]byte
counter uint64
buf []byte
offset int
eof bool
}
func newEncryptedFileReader(src io.Reader, aead cipher.AEAD) (*encryptedFileReader, error) {
reader := &encryptedFileReader{
src: src,
aead: aead,
}
header := make([]byte, len(encryptedFileMagic)+len(reader.noncePrefix))
if _, err := io.ReadFull(src, header); err != nil {
return nil, fmt.Errorf("read encrypted file header: %w", err)
}
if !equalBytes(header[:len(encryptedFileMagic)], encryptedFileMagic[:]) {
return nil, errors.New("invalid encrypted file header")
}
copy(reader.noncePrefix[:], header[len(encryptedFileMagic):])
return reader, nil
}
func (r *encryptedFileReader) Read(p []byte) (int, error) {
for r.offset >= len(r.buf) {
if r.eof {
return 0, io.EOF
}
if err := r.loadNextChunk(); err != nil {
return 0, err
}
}
n := copy(p, r.buf[r.offset:])
r.offset += n
return n, nil
}
func (r *encryptedFileReader) loadNextChunk() error {
var sizeBuf [4]byte
_, err := io.ReadFull(r.src, sizeBuf[:])
if err != nil {
if errors.Is(err, io.EOF) {
r.eof = true
return io.EOF
}
if errors.Is(err, io.ErrUnexpectedEOF) {
return errors.New("truncated encrypted chunk size")
}
return fmt.Errorf("read encrypted chunk size: %w", err)
}
chunkSize := binary.BigEndian.Uint32(sizeBuf[:])
if chunkSize == 0 {
return errors.New("invalid empty encrypted chunk")
}
maxChunkSize := uint32(encryptedChunkSize + r.aead.Overhead())
if chunkSize > maxChunkSize {
return fmt.Errorf("encrypted chunk too large: %d", chunkSize)
}
ciphertext := make([]byte, chunkSize)
if _, err := io.ReadFull(r.src, ciphertext); err != nil {
return fmt.Errorf("read encrypted chunk payload: %w", err)
}
nonce := make([]byte, r.aead.NonceSize())
copy(nonce, r.noncePrefix[:])
binary.BigEndian.PutUint64(nonce[len(r.noncePrefix):], r.counter)
plaintext, err := r.aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return fmt.Errorf("decrypt chunk: %w", err)
}
r.counter++
r.buf = plaintext
r.offset = 0
return nil
}
func equalBytes(a, b []byte) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func encryptIndexPayload(plaintext []byte, aead cipher.AEAD) ([]byte, error) {
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("generate index nonce: %w", err)
}
ciphertext := aead.Seal(nil, nonce, plaintext, nil)
out := make([]byte, 0, len(encryptedIndexMagic)+len(nonce)+len(ciphertext))
out = append(out, encryptedIndexMagic[:]...)
out = append(out, nonce...)
out = append(out, ciphertext...)
return out, nil
}
func decryptIndexPayload(raw []byte, aead cipher.AEAD) ([]byte, error) {
headerLen := len(encryptedIndexMagic)
nonceSize := aead.NonceSize()
if len(raw) < headerLen+nonceSize {
return nil, errors.New("encrypted index is too short")
}
if !equalBytes(raw[:headerLen], encryptedIndexMagic[:]) {
return nil, errors.New("invalid encrypted index header")
}
nonceStart := headerLen
nonceEnd := nonceStart + nonceSize
nonce := raw[nonceStart:nonceEnd]
ciphertext := raw[nonceEnd:]
if len(ciphertext) == 0 {
return nil, errors.New("encrypted index ciphertext is empty")
}
plaintext, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decrypt index payload: %w", err)
}
return plaintext, nil
}
+212 -278
View File
@@ -3,6 +3,11 @@ package storage
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -15,10 +20,25 @@ import (
"time"
)
var testEncryptionKey = []byte("0123456789abcdef0123456789abcdef")
func mustTestAEAD(t *testing.T) cipher.AEAD {
t.Helper()
block, err := aes.NewCipher(testEncryptionKey)
if err != nil {
t.Fatalf("aes.NewCipher() error = %v", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("cipher.NewGCM() error = %v", err)
}
return aead
}
func TestCreateStoresGzipAndOpenIsTransparent(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -36,8 +56,8 @@ func TestCreateStoresGzipAndOpenIsTransparent(t *testing.T) {
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if len(onDisk) < 2 || onDisk[0] != 0x1f || onDisk[1] != 0x8b {
t.Fatalf("stored file does not have gzip header")
if len(onDisk) < 4 || string(onDisk[:4]) != "SBX1" {
t.Fatalf("stored file does not have encrypted scratch header")
}
reader, _, err := store.Open(meta.ID)
@@ -54,10 +74,92 @@ func TestCreateStoresGzipAndOpenIsTransparent(t *testing.T) {
}
}
func TestCreateEncryptedStoreDoesNotExposeGzipHeaderOnDisk(t *testing.T) {
t.Parallel()
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatalf("rand.Read() error = %v", err)
}
dataDir := filepath.Join(t.TempDir(), "data")
store, err := NewFilesystemStore(dataDir, key)
if err != nil {
t.Fatalf("NewEncryptedFilesystemStore() error = %v", err)
}
original := bytes.Repeat([]byte("secret payload "), 1024)
meta, err := store.Create(context.Background(), bytes.NewReader(original), "text/plain", time.Hour)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
onDisk, err := os.ReadFile(meta.FilePath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if len(onDisk) < 2 {
t.Fatalf("encrypted file too short")
}
if onDisk[0] == 0x1f && onDisk[1] == 0x8b {
t.Fatalf("encrypted file unexpectedly starts with gzip header")
}
reader, _, err := store.Open(meta.ID)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
defer reader.Close()
got, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if !bytes.Equal(got, original) {
t.Fatalf("Open() content mismatch: got %d bytes, want %d", len(got), len(original))
}
filePayload, err := os.ReadFile(meta.FilePath)
if err != nil {
t.Fatalf("ReadFile(encrypted) error = %v", err)
}
sum := sha256.Sum256(filePayload)
wantID := hex.EncodeToString(sum[:])
if meta.ID != wantID {
t.Fatalf("meta.ID = %q, want SHA-256 of encrypted blob %q", meta.ID, wantID)
}
}
func TestEncryptedStoreFailsToOpenWithWrongKey(t *testing.T) {
t.Parallel()
keyA := make([]byte, 32)
if _, err := rand.Read(keyA); err != nil {
t.Fatalf("rand.Read(keyA) error = %v", err)
}
keyB := make([]byte, 32)
if _, err := rand.Read(keyB); err != nil {
t.Fatalf("rand.Read(keyB) error = %v", err)
}
dataDir := filepath.Join(t.TempDir(), "data")
storeA, err := NewFilesystemStore(dataDir, keyA)
if err != nil {
t.Fatalf("NewEncryptedFilesystemStore(keyA) error = %v", err)
}
_, err = storeA.Create(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if _, err := NewFilesystemStore(dataDir, keyB); err == nil {
t.Fatalf("NewFilesystemStore() with wrong key error = nil, want non-nil")
}
}
func TestCreateWithOriginalNameNormalizesFilename(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -70,7 +172,7 @@ func TestCreateWithOriginalNameNormalizesFilename(t *testing.T) {
t.Fatalf("meta.OriginalName = %q, want %q", meta.OriginalName, "demo.txt")
}
reloaded, err := NewFilesystemStore(store.dataDir)
reloaded, err := NewFilesystemStore(store.dataDir, testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() reload error = %v", err)
}
@@ -83,201 +185,31 @@ func TestCreateWithOriginalNameNormalizesFilename(t *testing.T) {
}
}
func TestCreateWithOriginalNameRetriesOnIDCollision(t *testing.T) {
func TestCreateWithOriginalNameSameContentGetsDifferentIDs(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
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)
first, err := store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("same body")), "text/plain", "one.txt", time.Hour)
if err != nil {
t.Fatalf("CreateWithOriginalName() error = %v", err)
t.Fatalf("first 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,
)
second, err := store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("same body")), "text/plain", "two.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)
if first.ID == second.ID {
t.Fatalf("IDs should differ because encrypted blobs include random nonce")
}
}
func TestGetDeleteAndNotFound(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -302,7 +234,7 @@ func TestGetDeleteAndNotFound(t *testing.T) {
func TestDeleteExpired(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -332,7 +264,7 @@ func TestDeleteExpired(t *testing.T) {
func TestOpenInvalidGzipFails(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -368,7 +300,7 @@ func TestNewFilesystemStoreInvalidIndex(t *testing.T) {
if err := os.WriteFile(filepath.Join(root, indexFilename), []byte("{bad"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := NewFilesystemStore(root); err == nil {
if _, err := NewFilesystemStore(root, testEncryptionKey); err == nil {
t.Fatalf("NewFilesystemStore() error = nil, want invalid index error")
}
}
@@ -419,15 +351,21 @@ func TestNewFilesystemStoreReconcileOnStartup(t *testing.T) {
Size: 1,
},
}
payload, err := json.Marshal(meta)
payload, err := json.Marshal(indexDocument{
Scratches: meta,
})
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
if err := os.WriteFile(filepath.Join(root, indexFilename), payload, 0o644); err != nil {
encryptedPayload, err := encryptIndexPayload(payload, mustTestAEAD(t))
if err != nil {
t.Fatalf("encryptIndexPayload() error = %v", err)
}
if err := os.WriteFile(filepath.Join(root, indexFilename), encryptedPayload, 0o644); err != nil {
t.Fatalf("WriteFile index error = %v", err)
}
store, err := NewFilesystemStore(root)
store, err := NewFilesystemStore(root, testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -442,6 +380,63 @@ func TestNewFilesystemStoreReconcileOnStartup(t *testing.T) {
}
}
func TestNewFilesystemStoreWithDefaultTTLShiftsExpiriesOnTTLChange(t *testing.T) {
t.Parallel()
dataDir := filepath.Join(t.TempDir(), "data")
store, err := NewFilesystemStoreWithDefaultTTL(dataDir, time.Hour, testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStoreWithDefaultTTL() error = %v", err)
}
meta, err := store.Create(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
originalExpiry := meta.ExpiresAt
reloaded, err := NewFilesystemStoreWithDefaultTTL(dataDir, 2*time.Hour, testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStoreWithDefaultTTL() reload error = %v", err)
}
got, err := reloaded.Get(meta.ID)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
wantExpiry := originalExpiry.Add(time.Hour)
if !got.ExpiresAt.Equal(wantExpiry) {
t.Fatalf("ExpiresAt = %s, want %s", got.ExpiresAt, wantExpiry)
}
again, err := NewFilesystemStoreWithDefaultTTL(dataDir, 2*time.Hour, testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStoreWithDefaultTTL() second reload error = %v", err)
}
gotAgain, err := again.Get(meta.ID)
if err != nil {
t.Fatalf("Get() second reload error = %v", err)
}
if !gotAgain.ExpiresAt.Equal(wantExpiry) {
t.Fatalf("ExpiresAt after unchanged TTL = %s, want %s", gotAgain.ExpiresAt, wantExpiry)
}
indexRaw, err := os.ReadFile(filepath.Join(dataDir, indexFilename))
if err != nil {
t.Fatalf("ReadFile(index) error = %v", err)
}
if bytes.Contains(indexRaw, []byte(`"default_ttl"`)) {
t.Fatalf("index should not be plaintext JSON")
}
indexPlaintext, err := decryptIndexPayload(indexRaw, mustTestAEAD(t))
if err != nil {
t.Fatalf("decryptIndexPayload() error = %v", err)
}
if !bytes.Contains(indexPlaintext, []byte(`"default_ttl": "2h0m0s"`)) {
t.Fatalf("decrypted index missing default ttl marker: %s", string(indexPlaintext))
}
}
type errCloser struct{}
func (errCloser) Close() error { return errors.New("close failed") }
@@ -465,7 +460,7 @@ func (failingReader) Read(_ []byte) (int, error) { return 0, errors.New("boom")
func TestCreateRollbackOnPersistFailure(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -487,7 +482,7 @@ func TestCreateRollbackOnPersistFailure(t *testing.T) {
func TestCreateReaderFailure(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -499,7 +494,7 @@ func TestCreateReaderFailure(t *testing.T) {
func TestDeletePersistFailureRestoresEntry(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -525,7 +520,7 @@ func TestDeletePersistFailureRestoresEntry(t *testing.T) {
func TestDeleteRemoveFailure(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -557,7 +552,7 @@ func TestDeleteRemoveFailure(t *testing.T) {
func TestDeleteExpiredPersistFailureRestoresEntries(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -583,7 +578,7 @@ func TestDeleteExpiredPersistFailureRestoresEntries(t *testing.T) {
func TestDeleteExpiredRemoveFailure(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
@@ -619,106 +614,45 @@ func TestNewFilesystemStoreFailsWhenDataDirIsFile(t *testing.T) {
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if _, err := NewFilesystemStore(filePath); err == nil {
if _, err := NewFilesystemStore(filePath, testEncryptionKey); err == nil {
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) {
func TestNewFilesystemStoreRequires32ByteKey(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
dataDir := filepath.Join(t.TempDir(), "data")
if _, err := NewFilesystemStore(dataDir, nil); err == nil {
t.Fatalf("NewFilesystemStore() with nil key error = nil, want non-nil")
}
if _, err := NewFilesystemStore(dataDir, []byte("short")); err == nil {
t.Fatalf("NewFilesystemStore() with short key error = nil, want non-nil")
}
}
func TestNewFilesystemStoreFailsToLoadEncryptedIndexWithWrongKey(t *testing.T) {
t.Parallel()
dataDir := filepath.Join(t.TempDir(), "data")
store, err := NewFilesystemStore(dataDir, testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
const reservedID = "ffffffffffffffffffffffff"
store.idGenerator = func() (string, error) {
return reservedID, nil
if _, err := store.Create(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", time.Hour); err != nil {
t.Fatalf("Create() error = %v", err)
}
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")
wrongKey := []byte("abcdef0123456789abcdef0123456789")
if _, err := NewFilesystemStore(dataDir, wrongKey); err == nil {
t.Fatalf("NewFilesystemStore() with wrong key error = nil, want non-nil")
}
}
func TestConcurrentCreateWithOriginalNameProducesUniqueReadableEntries(t *testing.T) {
t.Parallel()
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"), testEncryptionKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
+197 -163
View File
@@ -1,13 +1,12 @@
<script>
import { onDestroy, onMount } from "svelte";
let mode = $state("text");
let content = $state("");
let selectedFile = $state(null);
let loading = $state(false);
let errorMessage = $state("");
let maxUploadSizeBytes = $state(0);
let defaultTTL = $state("15m");
let uploadAllowed = $state(true);
let configError = $state("");
@@ -20,12 +19,16 @@
let viewContent = $state("");
let viewIsBinary = $state(false);
let viewIsImage = $state(false);
let viewIsVideo = $state(false);
let copyStatusMessage = $state("");
let copyStatusKind = $state("");
let refreshTriggered = $state(false);
let nowMs = $state(Date.now());
let nowTicker = null;
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
const defaultTTLDisplay = $derived(formatTTLDisplay(defaultTTL));
onMount(async () => {
nowTicker = window.setInterval(() => {
@@ -89,16 +92,18 @@
const max = Number.parseInt(String(data.max_upload_size_bytes ?? "0"), 10);
maxUploadSizeBytes = Number.isFinite(max) ? max : 0;
uploadAllowed = Boolean(data.upload_allowed);
defaultTTL = String(data.default_ttl ?? "").trim() || "15m";
} catch (err) {
configError = err instanceof Error ? err.message : String(err);
maxUploadSizeBytes = 0;
uploadAllowed = true;
defaultTTL = "15m";
}
}
async function loadScratchView() {
if (!scratchID) {
viewError = "Scratch id is missing.";
viewError = "Scratch ID is missing.";
return;
}
@@ -108,6 +113,9 @@
viewContent = "";
viewIsBinary = false;
viewIsImage = false;
viewIsVideo = false;
copyStatusMessage = "";
copyStatusKind = "";
refreshTriggered = false;
try {
@@ -119,14 +127,14 @@
return;
}
const msg = await metaResponse.text();
throw new Error(msg || `failed to load scratch metadata (${metaResponse.status})`);
throw new Error(msg || `Failed to load scratch metadata (${metaResponse.status}).`);
}
viewMeta = await metaResponse.json();
const rawResponse = await fetch(`/api/raw/${encodeURIComponent(scratchID)}`);
if (!rawResponse.ok) {
const msg = await rawResponse.text();
throw new Error(msg || `failed to load scratch content (${rawResponse.status})`);
throw new Error(msg || `Failed to load scratch content (${rawResponse.status}).`);
}
const contentType = rawResponse.headers.get("content-type") ?? "";
@@ -134,10 +142,17 @@
viewContent = await rawResponse.text();
viewIsBinary = false;
viewIsImage = false;
viewIsVideo = false;
} else if (isBrowserImageContentType(contentType)) {
viewIsImage = true;
viewIsBinary = false;
viewIsVideo = false;
} else if (isBrowserVideoContentType(contentType)) {
viewIsVideo = true;
viewIsImage = false;
viewIsBinary = false;
} else {
viewIsVideo = false;
viewIsImage = false;
viewIsBinary = true;
}
@@ -187,8 +202,81 @@
);
}
const browserImageContentTypes = new Set([
"image/apng",
"image/avif",
"image/gif",
"image/jpeg",
"image/png",
"image/svg+xml",
"image/webp",
"image/bmp",
"image/x-icon",
"image/vnd.microsoft.icon"
]);
const browserVideoContentTypes = new Set([
"video/mp4",
"video/webm",
"video/ogg"
]);
function normalizeContentType(contentType) {
return String(contentType).toLowerCase().split(";")[0].trim();
}
function isBrowserImageContentType(contentType) {
return String(contentType).toLowerCase().startsWith("image/");
return browserImageContentTypes.has(normalizeContentType(contentType));
}
function isBrowserVideoContentType(contentType) {
return browserVideoContentTypes.has(normalizeContentType(contentType));
}
function formatTTLDisplay(rawTTL) {
const source = String(rawTTL ?? "").trim().toLowerCase();
if (!source) {
return "15 minutes";
}
const matches = Array.from(source.matchAll(/(\d+)\s*([hms])/g));
if (matches.length === 0) {
return rawTTL;
}
let hours = 0;
let minutes = 0;
let seconds = 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") {
hours += value;
} else if (unit === "m") {
minutes += value;
} else if (unit === "s") {
seconds += value;
}
}
const parts = [];
if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
}
if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
}
if (seconds > 0) {
parts.push(`${seconds} ${seconds === 1 ? "second" : "seconds"}`);
}
if (parts.length === 0) {
return rawTTL;
}
return parts.join(" ");
}
$effect(() => {
@@ -231,21 +319,43 @@
return `${seconds}s`;
}
function switchMode(nextMode) {
if (loading || nextMode === mode) {
function onFileChange(event) {
selectedFile = event.currentTarget.files?.[0] ?? null;
}
async function copyScratchURL() {
if (!viewMeta?.raw_url) {
return;
}
mode = nextMode;
errorMessage = "";
if (mode === "text") {
selectedFile = null;
} else {
content = "";
copyStatusMessage = "";
copyStatusKind = "";
const absoluteRawURL = new URL(String(viewMeta.raw_url), window.location.origin).toString();
try {
await navigator.clipboard.writeText(absoluteRawURL);
copyStatusMessage = "Copied download URL.";
copyStatusKind = "success";
} catch {
copyStatusMessage = "Unable to copy URL. Copy it manually from the address bar.";
copyStatusKind = "error";
}
}
function onFileChange(event) {
selectedFile = event.currentTarget.files?.[0] ?? null;
function openScratchRawURL() {
if (!viewMeta?.raw_url) {
return;
}
const absoluteRawURL = new URL(String(viewMeta.raw_url), window.location.origin).toString();
window.open(absoluteRawURL, "_blank", "noopener,noreferrer");
}
function navigateTo(path) {
const destination = String(path ?? "").trim();
if (!destination) {
return;
}
window.location.assign(destination);
}
async function onSubmit(event) {
@@ -260,40 +370,23 @@
errorMessage = "";
const trimmedContent = content.trim();
if (mode === "text") {
if (!trimmedContent) {
errorMessage = "Enter text content before creating a scratch.";
return;
}
} else {
if (!selectedFile) {
errorMessage = "Choose a file before creating a scratch.";
return;
}
if (maxUploadSizeBytes > 0 && selectedFile.size > maxUploadSizeBytes) {
errorMessage = `Selected file is too large (${formatBytes(selectedFile.size, true)}). Max upload size is ${maxUploadSizeDisplay}.`;
return;
}
if (!selectedFile) {
errorMessage = "Choose a file before creating a scratch.";
return;
}
if (maxUploadSizeBytes > 0 && selectedFile.size > maxUploadSizeBytes) {
errorMessage = `Selected file is too large (${formatBytes(selectedFile.size, true)}). Max upload size is ${maxUploadSizeDisplay}.`;
return;
}
loading = true;
try {
let response;
if (mode === "file") {
const formData = new FormData();
formData.append("file", selectedFile);
response = await fetch("/api/scratch", {
method: "POST",
body: formData
});
} else {
response = await fetch("/api/scratch", {
method: "POST",
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: trimmedContent
});
}
const formData = new FormData();
formData.append("file", selectedFile);
const response = await fetch("/api/scratch", {
method: "POST",
body: formData
});
if (!response.ok) {
const message = await response.text();
@@ -308,7 +401,7 @@
window.location.assign(viewURL);
return;
} catch (err) {
if (mode === "file" && err instanceof TypeError) {
if (err instanceof TypeError) {
errorMessage = `Upload failed before the server returned a response. Max upload size is ${maxUploadSizeDisplay}.`;
} else {
errorMessage = err instanceof Error ? err.message : String(err);
@@ -323,22 +416,20 @@
<header class="site-header">
<div class="brand-row">
<a class="brand" href="/">Scratchbox</a>
<span class="tagline">Quick unauthenticated scratch uploads</span>
</div>
<nav class="site-nav" aria-label="Primary">
<a class="link-button nav-button" href="/">Home</a>
<button class="link-button nav-button action-button" type="button" onclick={() => navigateTo("/")}>Home</button>
{#if uploadAllowed}
<a class="link-button nav-button" href="/u">Upload</a>
<button class="link-button nav-button action-button" type="button" onclick={() => navigateTo("/u")}>Upload</button>
{/if}
</nav>
</header>
{#if routeMode === "home"}
<section class="panel">
<h1>Share scratch files and text quickly</h1>
<h1>Share scratch files quickly</h1>
<p>
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.
Scratchbox is a minimal temporary upload service with expiration.
</p>
<p>
No accounts, no sessions, no friction. Create a scratch, grab the URL, and share it.
@@ -350,7 +441,7 @@
{:else if routeMode === "view"}
<section class="panel">
{#if viewLoading}
<p>Loading scratch...</p>
<p>Loading scratch content...</p>
{:else if viewError}
<section id="error" class="error">{viewError}</section>
{:else if viewMeta}
@@ -361,21 +452,29 @@
alt={`Scratch ${scratchID}`}
loading="lazy"
/>
{:else if viewIsBinary}
<p>This scratch looks binary and is not rendered in-page. Use the raw link.</p>
{:else}
{:else if viewIsVideo}
<video class="scratch-video" controls preload="metadata">
<source src={viewMeta.raw_url} type={viewMeta.content_type} />
Your browser does not support inline video playback.
</video>
{:else if !viewIsBinary}
<pre>{viewContent}</pre>
{/if}
<p><strong>Expires in:</strong> {expiresInText(viewMeta.expires_at, nowMs)}</p>
<p><a class="link-button" href={viewMeta.raw_url} target="_blank" rel="noreferrer">Download</a></p>
<p class="scratch-actions">
<button class="link-button action-button" type="button" onclick={openScratchRawURL}>Download</button>
<button class="link-button action-button" type="button" onclick={copyScratchURL}>Copy Link</button>
</p>
{#if copyStatusMessage}
<p class={copyStatusKind === "error" ? "copy-status error" : "copy-status"}>{copyStatusMessage}</p>
{/if}
{/if}
</section>
{:else if routeMode === "upload"}
<section class="panel">
<h1>Upload</h1>
<p>Create an unauthenticated scratch. It expires automatically.</p>
<p class="helper">Max upload size: {maxUploadSizeDisplay}</p>
<p class="helper">Size limit: {maxUploadSizeDisplay}</p>
<p class="helper">Expiration: {defaultTTLDisplay}</p>
{#if configError}
<p class="helper warning">Could not load UI config: {configError}</p>
{/if}
@@ -385,59 +484,18 @@
</section>
{:else}
<form id="scratch-form" onsubmit={onSubmit}>
<fieldset class="mode-picker">
<legend>Upload mode</legend>
<div class="mode-buttons" role="tablist" aria-label="Upload mode">
<button
id="mode-text"
type="button"
class:mode-button={true}
class:is-active={mode === "text"}
aria-pressed={mode === "text"}
onclick={() => switchMode("text")}
>
Text
</button>
<button
id="mode-file"
type="button"
class:mode-button={true}
class:is-active={mode === "file"}
aria-pressed={mode === "file"}
onclick={() => switchMode("file")}
>
File
</button>
</div>
</fieldset>
<section id="file-panel" class="input-panel">
<label for="file">Select a file to upload</label>
<input
id="file"
name="file"
type="file"
onchange={onFileChange}
disabled={loading}
/>
</section>
{#if mode === "text"}
<section id="text-panel" class="input-panel">
<label for="content">Text content</label>
<textarea
id="content"
name="content"
rows="14"
placeholder="Paste text here"
bind:value={content}
disabled={loading}
></textarea>
</section>
{:else}
<section id="file-panel" class="input-panel">
<label for="file">File upload</label>
<input
id="file"
name="file"
type="file"
onchange={onFileChange}
disabled={loading}
/>
<p class="helper">Upload one file per scratch.</p>
</section>
{/if}
<button type="submit" disabled={loading}>
<button class="link-button nav-button action-button submit-button" type="submit" disabled={loading}>
{#if loading}Creating...{:else}Create scratch{/if}
</button>
</form>
@@ -459,9 +517,6 @@
</section>
{/if}
<footer class="site-footer">
<p>Scratchbox - simple temporary uploads</p>
</footer>
</main>
<style>
@@ -479,15 +534,13 @@
}
.site-header,
.site-footer,
.panel {
border: 1px solid #2a3243;
border-radius: 10px;
background: #101726;
}
.site-header,
.site-footer {
.site-header {
padding: 1rem 1.1rem;
}
@@ -511,11 +564,6 @@
color: #f5f7fc;
}
.tagline {
color: #b1b7c7;
font-size: 0.95rem;
}
.site-nav {
display: flex;
gap: 0.85rem;
@@ -526,11 +574,6 @@
padding: 1.2rem;
}
.site-footer {
margin-top: 1rem;
color: #b1b7c7;
}
.helper {
color: #b1b7c7;
margin-top: 0.5rem;
@@ -559,38 +602,26 @@
color: #f4c16e;
}
.mode-picker,
.scratch-actions {
display: flex;
align-items: center;
gap: 0.6rem;
}
.action-button {
cursor: pointer;
}
.copy-status {
margin-top: 0.35rem;
color: #9be8ac;
}
.input-panel,
#error {
margin-top: 1rem;
}
.mode-picker {
border: 1px solid #2a3243;
border-radius: 8px;
padding: 0.75rem;
}
.mode-buttons {
display: flex;
gap: 0.5rem;
}
.mode-button {
border: 1px solid #2a3243;
border-radius: 8px;
background: #121826;
color: #e7ebf3;
padding: 0.4rem 0.8rem;
cursor: pointer;
}
.mode-button.is-active {
background: #2b4b89;
border-color: #4a75c4;
}
textarea,
input[type="file"] {
margin-top: 0.5rem;
width: 100%;
@@ -601,14 +632,8 @@
padding: 0.7rem;
}
button[type="submit"] {
.submit-button {
margin-top: 1rem;
border: 1px solid #2f67c8;
background: #2b4b89;
color: #f2f5fb;
border-radius: 8px;
padding: 0.6rem 1rem;
cursor: pointer;
}
button[disabled] {
@@ -635,6 +660,15 @@
background: #0b0e14;
}
.scratch-video {
display: block;
width: 100%;
max-height: 70vh;
border: 1px solid #2a3243;
border-radius: 8px;
background: #0b0e14;
}
.error {
color: #ff9b9b;
}