Heavy security and file splitting
CI / Go Tests (push) Successful in 15s
CI / Build (push) Successful in 9s
Format / gofmt (push) Successful in 7s
Release Artifacts / Validate release tag (push) Successful in 2s
Release Artifacts / Build and release executables (push) Successful in 15s
Release Artifacts / Build and release Docker image (push) Successful in 28s

This commit is contained in:
2026-06-01 20:15:28 -05:00
parent bdbe1a9416
commit ad4355df17
27 changed files with 1540 additions and 1166 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ func TestWorkerStartRemovesExpiredAndLogs(t *testing.T) {
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
if _, err := store.Create(context.Background(), strings.NewReader("expired"), "text/plain", -time.Second); err != nil {
if _, err := store.Create(strings.NewReader("expired"), "text/plain", -time.Second); err != nil {
t.Fatalf("Create() error = %v", err)
}
@@ -59,7 +59,7 @@ func TestWorkerStartWithNoExpiredEntries(t *testing.T) {
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
if _, err := store.Create(context.Background(), strings.NewReader("active"), "text/plain", time.Hour); err != nil {
if _, err := store.Create(strings.NewReader("active"), "text/plain", time.Hour); err != nil {
t.Fatalf("Create() error = %v", err)
}
+59 -14
View File
@@ -22,6 +22,7 @@ const (
defaultReadTimeout = "30s"
defaultWriteTimeout = "30s"
defaultIdleTimeout = "120s"
defaultShutdownTimeout = "10s"
defaultMaxHeaderBytes = 1 << 20
defaultAccessLogEnabled = true
defaultAccessLogMaxSize = "100MiB"
@@ -42,6 +43,7 @@ const (
defaultAPIReadRateLimitBurst = 100
defaultAPIWriteRequestsPerMinute = 30
defaultAPIWriteRateLimitBurst = 10
defaultHSTSEnabled = true
maxDefaultTTLLimit = 24 * time.Hour
minCleanupInterval = 10 * time.Second
envPrefix = "SCRATCHBOX_"
@@ -51,7 +53,7 @@ type Config struct {
Server ServerConfig `yaml:"server" comment:"HTTP listener settings."`
Limits LimitsConfig `yaml:"limits" comment:"Upload, expiration, and raw cache defaults."`
Storage StorageConfig `yaml:"storage" comment:"Filesystem storage location and cleanup cadence."`
Security SecurityConfig `yaml:"security" comment:"Write-route IP restrictions and rate limiting."`
Security SecurityConfig `yaml:"security" comment:"Write-route IP restrictions, rate limiting, and HSTS."`
}
type ServerConfig struct {
@@ -60,12 +62,14 @@ type ServerConfig struct {
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."`
ShutdownTimeout string `yaml:"shutdown_timeout" comment:"Graceful shutdown timeout for the HTTP server. Must be > 0."`
MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."`
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:"-"`
ShutdownTimeoutDur time.Duration `yaml:"-"`
}
type AccessLogConfig struct {
@@ -96,10 +100,11 @@ type StorageConfig struct {
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."`
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. Must be the *immediate* proxies only (never 0/0 or untrusted ranges)."`
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)."`
HSTSEnabled bool `yaml:"hsts_enabled" comment:"Enable Strict-Transport-Security (HSTS) header. Recommended when behind a TLS-terminating reverse proxy for public deployments (default: true). Set to false for local-only HTTP use without proxies."`
AllowedPrefixes []netip.Prefix `yaml:"-"`
TrustedProxyPrefixes []netip.Prefix `yaml:"-"`
}
@@ -150,6 +155,7 @@ func defaults() Config {
ReadTimeout: defaultReadTimeout,
WriteTimeout: defaultWriteTimeout,
IdleTimeout: defaultIdleTimeout,
ShutdownTimeout: defaultShutdownTimeout,
MaxHeaderBytes: defaultMaxHeaderBytes,
AccessLog: AccessLogConfig{
Enabled: defaultAccessLogEnabled,
@@ -186,6 +192,7 @@ func defaults() Config {
RequestsPerMinute: defaultAPIWriteRequestsPerMinute,
Burst: defaultAPIWriteRateLimitBurst,
},
HSTSEnabled: defaultHSTSEnabled,
},
}
}
@@ -230,6 +237,15 @@ func (c *Config) Validate() error {
}
c.Server.IdleTimeoutDur = idleTimeout
shutdownTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.ShutdownTimeout))
if err != nil {
return fmt.Errorf("server.shutdown_timeout invalid: %w", err)
}
if shutdownTimeout <= 0 {
return errors.New("server.shutdown_timeout must be > 0")
}
c.Server.ShutdownTimeoutDur = shutdownTimeout
if c.Server.MaxHeaderBytes <= 0 {
return errors.New("server.max_header_bytes must be > 0")
}
@@ -278,18 +294,10 @@ func (c *Config) Validate() error {
if strings.TrimSpace(c.Storage.DataDir) == "" {
return errors.New("storage.data_dir is required")
}
if err := ensureWritableDir(c.Storage.DataDir); err != nil {
return fmt.Errorf("storage.data_dir not writable: %w", err)
}
encKey, err := ensureEncryptionKeyInDataDir(c.Storage.DataDir)
if err != nil {
return fmt.Errorf("storage encryption key setup failed: %w", err)
}
if len(encKey) != 32 {
return fmt.Errorf("storage encryption key must decode to 32 bytes, got %d", len(encKey))
}
c.Storage.EncryptionKeyBytes = encKey
// NOTE: directory creation + encryption key provisioning (with auto-gen on empty dir)
// moved to PrepareDataDir() to keep Validate() free of filesystem side effects.
// Callers (server startup, tests) must invoke PrepareDataDir after successful Validate/Load
// if they need EncryptionKeyBytes populated and data dir ensured.
prefixes, err := parseAllowedPrefixes(c.Security.AllowedIPs)
if err != nil {
return fmt.Errorf("security.allowed_ips invalid: %w", err)
@@ -326,6 +334,31 @@ func (c *Config) Validate() error {
return nil
}
// PrepareDataDir performs the filesystem side effects that were previously inside Validate:
// ensures the data dir is creatable/writable (via probe), and ensures the encryption key
// file exists at data_dir/metadata.key (auto-generating a fresh one only for a truly empty dir).
// It populates Storage.EncryptionKeyBytes. This keeps Validate pure (checks + duration/size
// parsing only). Must be called after Load/Validate (or on a manually built Config) before
// constructing a FilesystemStore that needs the key. Idempotent for subsequent calls if key
// already present.
func (c *Config) PrepareDataDir() error {
if strings.TrimSpace(c.Storage.DataDir) == "" {
return errors.New("storage.data_dir is required")
}
if err := ensureWritableDir(c.Storage.DataDir); err != nil {
return fmt.Errorf("storage.data_dir not writable: %w", err)
}
encKey, err := ensureEncryptionKeyInDataDir(c.Storage.DataDir)
if err != nil {
return fmt.Errorf("storage encryption key setup failed: %w", err)
}
if len(encKey) != 32 {
return fmt.Errorf("storage encryption key must decode to 32 bytes, got %d", len(encKey))
}
c.Storage.EncryptionKeyBytes = encKey
return nil
}
func ensureWritableDir(dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
@@ -413,6 +446,11 @@ func parseHumanSize(raw string) (int64, error) {
size := int64(number * multiplier)
if size <= 0 {
if number > 0 {
// overflow or truncation from huge float (e.g. 1e20 * GiB); guard before
// passing surprising/negative limit downstream.
return 0, errors.New("size too large")
}
return 0, errors.New("calculated size must be > 0")
}
return size, nil
@@ -508,6 +546,9 @@ func (c *Config) applyEnvOverrides(prefix string) error {
if err := applyStringEnv(prefix+"SERVER_IDLE_TIMEOUT", &c.Server.IdleTimeout); err != nil {
return err
}
if err := applyStringEnv(prefix+"SERVER_SHUTDOWN_TIMEOUT", &c.Server.ShutdownTimeout); err != nil {
return err
}
if err := applyIntEnv(prefix+"SERVER_MAX_HEADER_BYTES", &c.Server.MaxHeaderBytes); err != nil {
return err
}
@@ -580,6 +621,10 @@ func (c *Config) applyEnvOverrides(prefix string) error {
return err
}
if err := applyBoolEnv(prefix+"SECURITY_HSTS_ENABLED", &c.Security.HSTSEnabled); err != nil {
return err
}
return nil
}
+57 -5
View File
@@ -19,6 +19,9 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate() returned error: %v", err)
}
if err := cfg.PrepareDataDir(); err != nil {
t.Fatalf("PrepareDataDir() returned error: %v", err)
}
if cfg.Limits.MaxUploadSizeBytes != 100*1024*1024 {
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(100*1024*1024))
@@ -35,6 +38,9 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
if cfg.Server.IdleTimeoutDur != 120*time.Second {
t.Fatalf("IdleTimeoutDur = %s, want 120s", cfg.Server.IdleTimeoutDur)
}
if cfg.Server.ShutdownTimeoutDur != 10*time.Second {
t.Fatalf("ShutdownTimeoutDur = %s, want 10s", cfg.Server.ShutdownTimeoutDur)
}
if cfg.Server.MaxHeaderBytes != 1<<20 {
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 1<<20)
}
@@ -68,6 +74,9 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
if got := cfg.Security.AllowedPrefixes[0]; got != netip.MustParsePrefix("127.0.0.1/32") {
t.Fatalf("AllowedPrefixes[0] = %v, want 127.0.0.1/32", got)
}
if !cfg.Security.HSTSEnabled {
t.Fatalf("HSTSEnabled = false, want true (default)")
}
}
func TestLoadParsesConfiguredFields(t *testing.T) {
@@ -116,6 +125,7 @@ security:
enabled: true
requests_per_minute: 12
burst: 4
hsts_enabled: false
`
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
@@ -125,6 +135,9 @@ security:
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err := cfg.PrepareDataDir(); err != nil {
t.Fatalf("PrepareDataDir() error = %v", err)
}
if cfg.Server.ListenAddr != "127.0.0.1:9090" {
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, "127.0.0.1:9090")
@@ -141,6 +154,9 @@ security:
if cfg.Server.IdleTimeoutDur != 90*time.Second {
t.Fatalf("IdleTimeoutDur = %s, want 90s", cfg.Server.IdleTimeoutDur)
}
if cfg.Server.ShutdownTimeoutDur != 10*time.Second {
t.Fatalf("ShutdownTimeoutDur = %s, want 10s", cfg.Server.ShutdownTimeoutDur)
}
if cfg.Server.MaxHeaderBytes != 2*1024*1024 {
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 2*1024*1024)
}
@@ -186,6 +202,9 @@ security:
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 12 || cfg.Security.RateLimitAPIWrite.Burst != 4 {
t.Fatalf("unexpected rate_limit_api_write values: %+v", cfg.Security.RateLimitAPIWrite)
}
if cfg.Security.HSTSEnabled {
t.Fatalf("HSTSEnabled = true, want false (from config)")
}
if len(cfg.Security.AllowedPrefixes) != 2 {
t.Fatalf("AllowedPrefixes len = %d, want 2", len(cfg.Security.AllowedPrefixes))
}
@@ -244,6 +263,13 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
},
wantErr: "server.idle_timeout",
},
{
name: "invalid shutdown timeout",
mutate: func(cfg *Config) {
cfg.Server.ShutdownTimeout = "bogus"
},
wantErr: "server.shutdown_timeout",
},
{
name: "invalid max header bytes",
mutate: func(cfg *Config) {
@@ -432,13 +458,23 @@ storage:
t.Fatalf("Load() error = %v, want nil", err)
}
// First prepare creates the data dir (mkdir probe) so we can write the bad key file for test.
// (Load no longer has side effects.)
cfgForBad, err := Load(validConfigPath)
if err != nil {
t.Fatalf("Load(valid) error = %v", err)
}
if err := cfgForBad.PrepareDataDir(); err != nil {
t.Fatalf("PrepareDataDir (to create dir) error = %v", err)
}
keyPath := filepath.Join(tmp, "store", dataDirEncryptionKeyFilename)
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 setup failed") {
t.Fatalf("Load() error = %v, want invalid key base64 error", err)
// Re-prepare (or load+prepare) now hits the bad key decode inside ensure.
if err := cfgForBad.PrepareDataDir(); err == nil || !strings.Contains(err.Error(), "storage encryption key setup failed") {
t.Fatalf("PrepareDataDir after bad key error = %v, want setup failed containing 'storage encryption key setup failed'", err)
}
}
@@ -465,9 +501,13 @@ storage:
t.Fatalf("WriteFile(config) error = %v", err)
}
_, err := Load(configPath)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v, want success (pure validate)", err)
}
err = cfg.PrepareDataDir()
if err == nil || !strings.Contains(err.Error(), "refusing to generate missing") {
t.Fatalf("Load() error = %v, want missing-key safety error", err)
t.Fatalf("PrepareDataDir() error = %v, want missing-key safety error containing 'refusing to generate missing'", err)
}
}
@@ -479,6 +519,7 @@ func TestLoadFromEnvParsesConfiguredFields(t *testing.T) {
t.Setenv("SCRATCHBOX_SERVER_READ_TIMEOUT", "20s")
t.Setenv("SCRATCHBOX_SERVER_WRITE_TIMEOUT", "40s")
t.Setenv("SCRATCHBOX_SERVER_IDLE_TIMEOUT", "80s")
t.Setenv("SCRATCHBOX_SERVER_SHUTDOWN_TIMEOUT", "5s")
t.Setenv("SCRATCHBOX_SERVER_MAX_HEADER_BYTES", "2097152")
t.Setenv("SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED", "false")
t.Setenv("SCRATCHBOX_SERVER_ACCESS_LOG_FILE_PATH", "/tmp/access.log")
@@ -505,11 +546,15 @@ func TestLoadFromEnvParsesConfiguredFields(t *testing.T) {
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_ENABLED", "true")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE", "40")
t.Setenv("SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_BURST", "12")
t.Setenv("SCRATCHBOX_SECURITY_HSTS_ENABLED", "false")
cfg, err := LoadFromEnv()
if err != nil {
t.Fatalf("LoadFromEnv() error = %v", err)
}
if err := cfg.PrepareDataDir(); err != nil {
t.Fatalf("PrepareDataDir() error = %v", err)
}
if cfg.Server.ListenAddr != "127.0.0.1:19090" {
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, "127.0.0.1:19090")
@@ -526,6 +571,9 @@ func TestLoadFromEnvParsesConfiguredFields(t *testing.T) {
if cfg.Server.IdleTimeoutDur != 80*time.Second {
t.Fatalf("IdleTimeoutDur = %s, want 80s", cfg.Server.IdleTimeoutDur)
}
if cfg.Server.ShutdownTimeoutDur != 5*time.Second {
t.Fatalf("ShutdownTimeoutDur = %s, want 5s", cfg.Server.ShutdownTimeoutDur)
}
if cfg.Server.MaxHeaderBytes != 2*1024*1024 {
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 2*1024*1024)
}
@@ -571,6 +619,9 @@ func TestLoadFromEnvParsesConfiguredFields(t *testing.T) {
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 40 || cfg.Security.RateLimitAPIWrite.Burst != 12 {
t.Fatalf("unexpected rate_limit_api_write values: %+v", cfg.Security.RateLimitAPIWrite)
}
if cfg.Security.HSTSEnabled {
t.Fatalf("HSTSEnabled from env = true, want false")
}
}
func TestLoadFromEnvRejectsInvalidBool(t *testing.T) {
@@ -616,6 +667,7 @@ func TestParseHumanSizeVariants(t *testing.T) {
{in: "abc", wantErr: "does not start with a number"},
{in: "5XB", wantErr: "unsupported unit"},
{in: "", wantErr: "size is empty"},
{in: "100000000000000000GiB", wantErr: "size too large"},
}
for _, tc := range tests {
+89 -14
View File
@@ -69,16 +69,36 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /assets/", http.FileServerFS(webassets.StaticFS()))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(webassets.StaticFS())))
// Special-case well-known paths to return small plain-text responses instead of full SPA 404 shell.
// This addresses low-priority audit item for robots.txt etc.
mux.Handle("GET /robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("User-agent: *\nDisallow: /\n"))
}))
mux.Handle("GET /sitemap.xml", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
}))
// Serve the real favicon (added to eliminate 404 noise). Placed in static root via public/ in Vite build.
mux.Handle("GET /favicon.svg", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFileFS(w, r, webassets.StaticFS(), "favicon.svg")
}))
mux.Handle("GET /{path...}", http.HandlerFunc(s.notFoundPage))
return AccessLogMiddleware(
accessLogged := AccessLogMiddleware(
s.accessLogger,
s.cfg.Security.TrustProxyHeaders,
s.cfg.Security.TrustedProxyPrefixes,
)(mux)
return SecurityHeadersMiddleware(s.cfg.Security.HSTSEnabled, accessLogged)
}
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
s.serveSPAWithStatus(w, http.StatusOK)
s.serveSPAWithStatus(w, http.StatusOK, nil)
}
func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
@@ -91,19 +111,22 @@ func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) notFoundPage(w http.ResponseWriter, _ *http.Request) {
s.serveSPAWithStatus(w, http.StatusNotFound)
s.serveSPAWithStatus(w, http.StatusNotFound, nil)
}
func (s *Server) expiredScratchPage(w http.ResponseWriter) {
s.serveSPAWithStatus(w, http.StatusGone)
s.serveSPAWithStatus(w, http.StatusGone, nil)
}
func (s *Server) serveSPAWithStatus(w http.ResponseWriter, status int) {
func (s *Server) serveSPAWithStatus(w http.ResponseWriter, status int, modifier func([]byte) []byte) {
b, err := fs.ReadFile(webassets.StaticFS(), "index.html")
if err != nil {
http.Error(w, "failed to render page", http.StatusInternalServerError)
return
}
if modifier != nil {
b = modifier(b)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(b)
@@ -117,17 +140,36 @@ func (s *Server) scratchPage(w http.ResponseWriter, r *http.Request) {
}
meta, err := s.store.Get(id)
if err != nil {
s.expiredScratchPage(w)
return
}
if s.isExpired(meta) {
_ = s.store.Delete(id)
s.expiredScratchPage(w)
if err != nil || s.isExpired(meta) {
if err == nil {
_ = s.store.Delete(id)
}
// For /s/{id} 410/expired (including never-existed), inject id/filename based title+meta even on 410 shell.
// This provides good SEO/social for share links and bots (even on 410), per audit rec.
// We use the id (and name if we had meta before delete) for title.
goneTitle := id + " • Scratchbox"
if err == nil && meta.OriginalName != "" {
goneTitle = meta.OriginalName + " • Scratchbox"
}
s.serveSPAWithStatus(w, http.StatusGone, func(b []byte) []byte {
return injectMeta(b, goneTitle, goneTitle, "This scratch is no longer available.")
})
return
}
s.serveSPA(w, r)
// Valid scratch: serve shell with per-scratch title and og meta injected server-side.
// (Client-side <svelte:head> also updates for SPA, but server inj benefits crawlers/JS-disabled.)
title := id + " • Scratchbox"
if meta.OriginalName != "" {
title = meta.OriginalName + " • Scratchbox"
}
desc := "Temporary scratch file shared on Scratchbox."
if meta.OriginalName != "" {
desc = meta.OriginalName + " — temporary file share on Scratchbox."
}
s.serveSPAWithStatus(w, http.StatusOK, func(b []byte) []byte {
return injectMeta(b, title, title, desc)
})
}
func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
@@ -180,7 +222,7 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
}
}
meta, err := s.store.CreateWithOriginalName(r.Context(), reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration)
meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
@@ -332,3 +374,36 @@ func (s *Server) isUploadAllowedForRequest(r *http.Request) bool {
return false
}
// injectMeta performs lightweight server-side injection of <title> and social meta tags
// into the static index.html shell bytes. Uses simple string replace (safe for this tiny known shell,
// no new deps). Generic pages keep the static metas from the HTML template; /s/{id} get dynamic.
func injectMeta(html []byte, pageTitle, ogTitle, ogDescription string) []byte {
if pageTitle == "" {
pageTitle = "Scratchbox"
}
if ogTitle == "" {
ogTitle = pageTitle
}
if ogDescription == "" {
ogDescription = "Minimal temporary file sharing. Upload once, share the link, auto-expires."
}
s := string(html)
// Override <title>
s = strings.Replace(s, "<title>Scratchbox</title>", "<title>"+htmlEscape(pageTitle)+"</title>", 1)
// Override the static og/twitter metas added to the shells (first occurrence only; safe).
s = strings.Replace(s, `<meta property="og:title" content="Scratchbox" />`, `<meta property="og:title" content="`+htmlEscape(ogTitle)+`" />`, 1)
s = strings.Replace(s, `<meta name="twitter:title" content="Scratchbox" />`, `<meta name="twitter:title" content="`+htmlEscape(ogTitle)+`" />`, 1)
s = strings.Replace(s, `<meta name="description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />`, `<meta name="description" content="`+htmlEscape(ogDescription)+`" />`, 1)
s = strings.Replace(s, `<meta property="og:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />`, `<meta property="og:description" content="`+htmlEscape(ogDescription)+`" />`, 1)
s = strings.Replace(s, `<meta name="twitter:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />`, `<meta name="twitter:description" content="`+htmlEscape(ogDescription)+`" />`, 1)
return []byte(s)
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
@@ -0,0 +1,249 @@
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
func TestConcurrentUploadsRemainConsistentAndReadable(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024 * 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
const uploads = 80
type createdScratch struct {
id string
body string
}
resultsCh := make(chan createdScratch, uploads)
errCh := make(chan error, uploads)
var wg sync.WaitGroup
for i := 0; i < uploads; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
body := fmt.Sprintf("parallel-upload-%03d-%s", i, strings.Repeat("x", i%17))
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 8200+i)
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
errCh <- fmt.Errorf("create status = %d, want %d", createRec.Code, http.StatusCreated)
return
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
errCh <- fmt.Errorf("decode create payload error: %w", err)
return
}
id, _ := payload["id"].(string)
if id == "" {
errCh <- fmt.Errorf("create payload missing id")
return
}
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
metaRec := httptest.NewRecorder()
handler.ServeHTTP(metaRec, metaReq)
if metaRec.Code != http.StatusOK {
errCh <- fmt.Errorf("metadata status = %d, want %d", metaRec.Code, http.StatusOK)
return
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusOK {
errCh <- fmt.Errorf("raw status = %d, want %d", rawRec.Code, http.StatusOK)
return
}
if got := rawRec.Body.String(); got != body {
errCh <- fmt.Errorf("raw body mismatch = %q, want %q", got, body)
return
}
resultsCh <- createdScratch{id: id, body: body}
}(i)
}
wg.Wait()
close(resultsCh)
close(errCh)
for err := range errCh {
t.Error(err)
}
seen := make(map[string]struct{}, uploads)
for result := range resultsCh {
if _, exists := seen[result.id]; exists {
t.Errorf("duplicate scratch id generated: %s", result.id)
continue
}
seen[result.id] = struct{}{}
}
if len(seen) != uploads {
t.Fatalf("unique IDs = %d, want %d", len(seen), uploads)
}
}
func TestParallelClientsCanReadSameScratchAcrossAllReadRoutes(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
const body = "parallel readers"
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.1:6010"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
paths := []string{
"/api/raw/" + id,
"/s/" + id,
"/api/scratch/" + id,
}
const requests = 120
errCh := make(chan error, requests)
var wg sync.WaitGroup
for i := 0; i < requests; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
path := paths[i%len(paths)]
req := httptest.NewRequest(http.MethodGet, path, nil)
req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 6100+i)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
errCh <- fmt.Errorf("path %s status = %d, want %d", path, rec.Code, http.StatusOK)
return
}
switch {
case strings.HasPrefix(path, "/api/raw/"):
if got := rec.Body.String(); got != body {
errCh <- fmt.Errorf("raw body = %q, want %q", got, body)
}
case strings.HasPrefix(path, "/s/"):
if !strings.Contains(rec.Body.String(), `id="app"`) {
errCh <- fmt.Errorf("view body missing SPA marker")
}
case strings.HasPrefix(path, "/api/scratch/"):
var gotPayload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &gotPayload); err != nil {
errCh <- fmt.Errorf("metadata decode error: %w", err)
return
}
if gotID, _ := gotPayload["id"].(string); gotID != id {
errCh <- fmt.Errorf("metadata id = %q, want %q", gotID, id)
}
}
}(i)
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Error(err)
}
}
func TestParallelClientsExpiredScratchReadsReturnGone(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: 80 * time.Millisecond,
rateLimitEnable: false,
})
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("expires soon"))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.1:7010"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
time.Sleep(130 * time.Millisecond)
paths := []string{
"/api/raw/" + id,
"/s/" + id,
"/api/scratch/" + id,
}
const requests = 90
errCh := make(chan error, requests)
var wg sync.WaitGroup
for i := 0; i < requests; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
path := paths[i%len(paths)]
req := httptest.NewRequest(http.MethodGet, path, nil)
req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 7100+i)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusGone {
errCh <- fmt.Errorf("expired path %s status = %d, want %d", path, rec.Code, http.StatusGone)
return
}
if strings.HasPrefix(path, "/s/") && !strings.Contains(rec.Body.String(), `id="app"`) {
errCh <- fmt.Errorf("expired view body missing SPA marker")
}
}(i)
}
wg.Wait()
close(errCh)
for err := range errCh {
t.Error(err)
}
}
@@ -0,0 +1,375 @@
package httpapi
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCreateReadThenExpireScratch(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: 120 * time.Millisecond,
rateLimitEnable: false,
})
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch"))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.1:9999"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
metaReq.RemoteAddr = "127.0.0.1:9999"
metaRec := httptest.NewRecorder()
handler.ServeHTTP(metaRec, metaReq)
if metaRec.Code != http.StatusOK {
t.Fatalf("read metadata status = %d, want %d", metaRec.Code, http.StatusOK)
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
rawReq.RemoteAddr = "127.0.0.1:9999"
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusOK {
t.Fatalf("read raw status = %d, want %d", rawRec.Code, http.StatusOK)
}
if got := rawRec.Body.String(); got != "hello scratch" {
t.Fatalf("raw body = %q, want %q", got, "hello scratch")
}
time.Sleep(180 * time.Millisecond)
expiredReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
expiredReq.RemoteAddr = "127.0.0.1:9999"
expiredRec := httptest.NewRecorder()
handler.ServeHTTP(expiredRec, expiredReq)
if expiredRec.Code != http.StatusGone {
t.Fatalf("expired status = %d, want %d", expiredRec.Code, http.StatusGone)
}
}
func TestUploadedScratchViewAndRawBeforeAndAfterExpiry(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: 120 * time.Millisecond,
rateLimitEnable: false,
})
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello from upload flow"))
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
createReq.RemoteAddr = "127.0.0.1:5090"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
viewReq.RemoteAddr = "127.0.0.1:5091"
viewRec := httptest.NewRecorder()
handler.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusOK {
t.Fatalf("view-before-expiry status = %d, want %d", viewRec.Code, http.StatusOK)
}
if !strings.Contains(viewRec.Body.String(), `id="app"`) {
t.Fatalf("expected SPA shell body for /s/{id}, got %q", viewRec.Body.String())
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
rawReq.RemoteAddr = "127.0.0.1:5092"
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusOK {
t.Fatalf("raw-before-expiry status = %d, want %d", rawRec.Code, http.StatusOK)
}
if got := rawRec.Body.String(); got != "hello from upload flow" {
t.Fatalf("raw-before-expiry body = %q, want %q", got, "hello from upload flow")
}
time.Sleep(180 * time.Millisecond)
viewExpiredReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
viewExpiredReq.RemoteAddr = "127.0.0.1:5093"
viewExpiredRec := httptest.NewRecorder()
handler.ServeHTTP(viewExpiredRec, viewExpiredReq)
if viewExpiredRec.Code != http.StatusGone {
t.Fatalf("view-after-expiry status = %d, want %d", viewExpiredRec.Code, http.StatusGone)
}
if !strings.Contains(viewExpiredRec.Body.String(), `id="app"`) {
t.Fatalf("expected SPA shell body for expired /s/{id}, got %q", viewExpiredRec.Body.String())
}
rawExpiredReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
rawExpiredReq.RemoteAddr = "127.0.0.1:5094"
rawExpiredRec := httptest.NewRecorder()
handler.ServeHTTP(rawExpiredRec, rawExpiredReq)
if rawExpiredRec.Code != http.StatusGone {
t.Fatalf("raw-after-expiry status = %d, want %d", rawExpiredRec.Code, http.StatusGone)
}
if rawExpiredRec.Body.Len() != 0 {
t.Fatalf("expected empty raw body after expiry, got %q", rawExpiredRec.Body.String())
}
}
func TestGetScratchNotFoundReturnsGone(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
viewReq := httptest.NewRequest(http.MethodGet, "/s/does-not-exist", nil)
viewReq.RemoteAddr = "127.0.0.1:5095"
viewRec := httptest.NewRecorder()
handler.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusGone {
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone)
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/does-not-exist", nil)
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusGone {
t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusGone)
}
if rawRec.Body.Len() != 0 {
t.Fatalf("expected empty response body, got %q", rawRec.Body.String())
}
}
func TestViewAndRawUnknownIDsReturnGone(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
viewReq := httptest.NewRequest(http.MethodGet, "/s/never-uploaded-id", nil)
viewReq.RemoteAddr = "127.0.0.1:5095"
viewRec := httptest.NewRecorder()
handler.ServeHTTP(viewRec, viewReq)
if viewRec.Code != http.StatusGone {
t.Fatalf("view-never-uploaded status = %d, want %d", viewRec.Code, http.StatusGone)
}
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/never-uploaded-id", nil)
rawReq.RemoteAddr = "127.0.0.1:5096"
rawRec := httptest.NewRecorder()
handler.ServeHTTP(rawRec, rawReq)
if rawRec.Code != http.StatusGone {
t.Fatalf("raw-never-uploaded status = %d, want %d", rawRec.Code, http.StatusGone)
}
if rawRec.Body.Len() != 0 {
t.Fatalf("expected empty raw body for never-uploaded id, got %q", rawRec.Body.String())
}
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/never-uploaded-id", nil)
metaReq.RemoteAddr = "127.0.0.1:5097"
metaRec := httptest.NewRecorder()
handler.ServeHTTP(metaRec, metaReq)
if metaRec.Code != http.StatusGone {
t.Fatalf("metadata-never-uploaded status = %d, want %d", metaRec.Code, http.StatusGone)
}
}
func TestCreateScratchRejectsMalformedMultipart(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
req := httptest.NewRequest(http.MethodPost, "/api/scratch", strings.NewReader("not-multipart"))
req.Header.Set("Content-Type", "multipart/form-data; boundary=missing")
req.RemoteAddr = "127.0.0.1:5057"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestUnknownPathReturnsNotFoundPage(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
req := httptest.NewRequest(http.MethodGet, "/this-path-does-not-exist", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
if !strings.Contains(rec.Body.String(), `id="app"`) {
t.Fatalf("unexpected not found body: %q", rec.Body.String())
}
}
// TestWellKnownPathsReturnPlainNotSPA verifies low-pri audit fix: robots/sitemap return small
// plain text (no full SPA html shell +404).
func TestWellKnownPathsReturnPlainNotSPA(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
// robots: 200 plain with disallow
req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/robots status=%d want 200", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if !strings.Contains(ct, "text/plain") {
t.Fatalf("/robots ct=%q want text/plain", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "User-agent: *") || strings.Contains(body, "id=\"app\"") || strings.Contains(body, "<!doctype") {
t.Fatalf("/robots body unexpected (should be small plain disallow, no SPA): %q", body)
}
// sitemap: 404 plain (no html)
req2 := httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil)
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusNotFound {
t.Fatalf("/sitemap status=%d want 404", rec2.Code)
}
ct2 := rec2.Header().Get("Content-Type")
if !strings.Contains(ct2, "text/plain") {
t.Fatalf("/sitemap ct=%q want text/plain", ct2)
}
body2 := rec2.Body.String()
if strings.Contains(body2, "id=\"app\"") || strings.Contains(body2, "<!doctype") || len(body2) > 100 {
t.Fatalf("/sitemap body unexpected (small plain 404, no SPA): %q", body2)
}
// normal unknown path still gets SPA 404 shell
req3 := httptest.NewRequest(http.MethodGet, "/not-a-wellknown", nil)
rec3 := httptest.NewRecorder()
handler.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusNotFound {
t.Fatalf("generic 404 status=%d want 404", rec3.Code)
}
if !strings.Contains(rec3.Body.String(), `id="app"`) {
t.Fatalf("generic 404 should still be SPA shell")
}
}
func TestFaviconServed(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
req := httptest.NewRequest(http.MethodGet, "/favicon.svg", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("favicon status = %d, want 200", rec.Code)
}
ct := rec.Header().Get("Content-Type")
if !strings.Contains(ct, "image/svg+xml") {
t.Fatalf("favicon ct=%q want image/svg+xml", ct)
}
}
func TestScratchPageServerMetaInjection(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
// create one with filename
var body bytes.Buffer
writer := multipart.NewWriter(&body)
file, _ := writer.CreateFormFile("file", "report.pdf")
file.Write([]byte("pdf-bytes"))
writer.Close()
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
createReq.Header.Set("Content-Type", writer.FormDataContentType())
createReq.RemoteAddr = "127.0.0.1:7777"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d", createRec.Code)
}
var payload map[string]any
json.Unmarshal(createRec.Body.Bytes(), &payload)
id := payload["id"].(string)
// view page should have injected title/meta
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
viewRec := httptest.NewRecorder()
handler.ServeHTTP(viewRec, viewReq)
bodyStr := viewRec.Body.String()
if !strings.Contains(bodyStr, "<title>report.pdf • Scratchbox</title>") {
t.Fatalf("expected injected title with filename, got head: %s", bodyStr[:min(800, len(bodyStr))])
}
if !strings.Contains(bodyStr, `property="og:title"`) {
t.Fatalf("expected og:title meta")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,79 @@
package httpapi
import (
"io"
"log"
"net/http"
"path/filepath"
"testing"
"time"
"scratchbox/internal/config"
"scratchbox/internal/storage"
)
var testStorageKey = []byte("0123456789abcdef0123456789abcdef")
type testServerOptions struct {
maxUploadBytes int64
defaultTTL time.Duration
rateLimitEnable bool
}
func newTestHandler(t *testing.T, opts testServerOptions) http.Handler {
t.Helper()
handler, _ := newTestHandlerAndStore(t, opts)
return handler
}
func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, *storage.FilesystemStore) {
t.Helper()
dataDir := filepath.Join(t.TempDir(), "data")
store, err := storage.NewFilesystemStore(dataDir, testStorageKey)
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
cfg := config.Config{
Server: config.ServerConfig{
ListenAddr: ":0",
},
Limits: config.LimitsConfig{
MaxUploadSizeBytes: opts.maxUploadBytes,
DefaultTTLDuration: opts.defaultTTL,
RawCacheMaxBytes: 64 * 1024 * 1024,
RawCacheMaxEntries: 32,
},
Storage: config.StorageConfig{
DataDir: dataDir,
},
Security: config.SecurityConfig{
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,
},
HSTSEnabled: true,
},
}
srv := &Server{
cfg: cfg,
store: store,
logger: log.New(io.Discard, "", 0),
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
}
return srv.Routes(), store
}
@@ -0,0 +1,101 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestReadRoutesAreRateLimitedWhenEnabled(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: true,
})
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.2:9111"
createRec := httptest.NewRecorder()
handler.ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
}
var payload map[string]any
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode create payload error = %v", err)
}
id, _ := payload["id"].(string)
if id == "" {
t.Fatalf("create payload missing id")
}
firstRawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
firstRawReq.RemoteAddr = "127.0.0.1:9111"
firstRawRec := httptest.NewRecorder()
handler.ServeHTTP(firstRawRec, firstRawReq)
if firstRawRec.Code != http.StatusOK {
t.Fatalf("first raw status = %d, want %d", firstRawRec.Code, http.StatusOK)
}
secondRawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
secondRawReq.RemoteAddr = "127.0.0.1:9111"
secondRawRec := httptest.NewRecorder()
handler.ServeHTTP(secondRawRec, secondRawReq)
if secondRawRec.Code != http.StatusTooManyRequests {
t.Fatalf("second raw status = %d, want %d", secondRawRec.Code, http.StatusTooManyRequests)
}
}
// TestSecurityHeadersOnAllResponses verifies the new always-on security headers middleware
// (wired early, applies to UI/API/assets/plain/other responses per audit).
func TestSecurityHeadersOnAllResponses(t *testing.T) {
t.Parallel()
handler := newTestHandler(t, testServerOptions{
maxUploadBytes: 1024,
defaultTTL: time.Hour,
rateLimitEnable: false,
})
cases := []struct {
path string
method string
}{
{"/", "GET"},
{"/u", "GET"},
{"/api/config", "GET"},
{"/this-is-404", "GET"},
{"/robots.txt", "GET"},
{"/favicon.svg", "GET"},
}
for _, c := range cases {
req := httptest.NewRequest(c.method, c.path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("path=%s X-Content-Type-Options=%q want nosniff", c.path, got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
t.Errorf("path=%s Referrer-Policy=%q want strict-origin-when-cross-origin", c.path, got)
}
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("path=%s X-Frame-Options=%q want DENY", c.path, got)
}
if got := rec.Header().Get("Strict-Transport-Security"); !strings.Contains(got, "max-age=31536000") {
t.Errorf("path=%s HSTS=%q want max-age", c.path, got)
}
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "frame-ancestors 'none'") {
t.Errorf("path=%s CSP=%q missing expected directives", c.path, csp)
}
}
}
File diff suppressed because it is too large Load Diff
+40 -2
View File
@@ -15,8 +15,6 @@ import (
"scratchbox/internal/storage"
)
var testStorageKey = []byte("0123456789abcdef0123456789abcdef")
func TestMultipartFileCount(t *testing.T) {
t.Parallel()
@@ -61,6 +59,7 @@ func TestNewServerInitializes(t *testing.T) {
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},
HSTSEnabled: true,
},
}
@@ -132,6 +131,7 @@ func TestWriteCreateErrorBranchesWithConfig(t *testing.T) {
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},
HSTSEnabled: true,
},
}
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0), log.New(io.Discard, "", 0)); err != nil {
@@ -147,3 +147,41 @@ func containsAll(haystack string, needles ...string) bool {
}
return true
}
// TestInjectMetaEscapesSpecialChars exercises the server-side meta injection
// (used for /s/{id} and 410 pages) with filenames/titles containing HTML special
// chars. This provides basic coverage/golden-like check for the replace+escape
// logic against the exact literals in the committed static shell (addresses
// fragility note without changing to full template).
func TestInjectMetaEscapesSpecialChars(t *testing.T) {
t.Parallel()
// Minimal shell containing exactly the replace targets used by injectMeta.
// (Real index.html from Vite is larger but uses these literal strings.)
shell := []byte(`<!doctype html>
<html><head>
<title>Scratchbox</title>
<meta property="og:title" content="Scratchbox" />
<meta name="twitter:title" content="Scratchbox" />
<meta name="description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
<meta property="og:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
<meta name="twitter:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
</head><body></body></html>`)
tricky := `report & "notes" <2026> 'foo'`
gotBytes := injectMeta(shell, tricky, tricky, tricky)
got := string(gotBytes)
// Must have escaped the specials.
if !strings.Contains(got, `&amp;`) || !strings.Contains(got, `&lt;`) || !strings.Contains(got, `&gt;`) || !strings.Contains(got, `&quot;`) {
t.Fatalf("injectMeta did not escape specials in output: %q", got)
}
// Title etc updated (escaped form present).
if !strings.Contains(got, `report &amp; &quot;notes&quot; &lt;2026&gt;`) {
t.Fatalf("injectMeta title not updated or badly escaped: %q", got)
}
// Original static should be gone (count=1 replaces).
if strings.Contains(got, `<title>Scratchbox</title>`) {
t.Fatalf("static title not replaced")
}
}
+39 -1
View File
@@ -18,6 +18,15 @@ type visitor struct {
lastSeen time.Time
}
// rateLimiterMaxVisitors caps the per-limiter visitor map (token buckets keyed by client IP).
// Prevents unbounded memory growth under many distinct clients (or DoS) while prune
// still removes stale (>ttl) entries on every Allow. 1024 is generous for the intended
// LAN/minimal-public use; scans are O(cap) worst case.
const (
rateLimiterMaxVisitors = 1024
rateLimiterPruneTTL = 10 * time.Minute
)
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
@@ -31,7 +40,7 @@ func NewRateLimiter(requestsPerMinute int, burst int) *RateLimiter {
visitors: map[string]*visitor{},
rate: rate.Limit(float64(requestsPerMinute) / 60.0),
burst: burst,
ttl: 10 * time.Minute,
ttl: rateLimiterPruneTTL,
}
}
@@ -53,6 +62,13 @@ func (r *RateLimiter) Allow(ip string) bool {
limiter: rate.NewLimiter(r.rate, r.burst),
}
r.visitors[ip] = v
if len(r.visitors) > rateLimiterMaxVisitors {
// evict arbitrary entry (prune already removed stales); keeps map bounded
for k := range r.visitors {
delete(r.visitors, k)
break
}
}
}
v.lastSeen = now
@@ -235,3 +251,25 @@ func writeJSON(w http.ResponseWriter, status int, payload any) {
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
// SecurityHeadersMiddleware sets safe default security headers on all responses.
// HSTS is conditional on the hstsEnabled toggle (default true in config for public proxy+TLS use;
// false allows pure local HTTP as noted in README LAN Deployment Notes).
// Other headers always-on per minimal security defaults.
// CSP uses 'unsafe-inline' for style-src (and data: for media) due to current Vite/Svelte
// build output; this is an acknowledged defense-in-depth tradeoff for the minimal trusted UI
// (script-src remains strict 'self'; no user-controlled content in styles).
func SecurityHeadersMiddleware(hstsEnabled bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("X-Frame-Options", "DENY")
if hstsEnabled {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self';")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
next.ServeHTTP(w, r)
})
}
+57
View File
@@ -321,3 +321,60 @@ func TestAccessLogMiddlewareOnlyAllowsConfiguredRoutes(t *testing.T) {
t.Fatalf("expected access log for allowed route, got %q", buf.String())
}
}
// TestSecurityHeadersMiddlewareSetsDefaults verifies the new middleware sets expected headers
// (X-Content-Type-Options, Referrer, CSP, HSTS conditional, etc.) on wrapped responses.
func TestSecurityHeadersMiddlewareSetsDefaults(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
// HSTS enabled (default)
handler := SecurityHeadersMiddleware(true, next)
req := httptest.NewRequest(http.MethodGet, "/anything", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d want 200", rec.Code)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options=%q want nosniff", got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
t.Fatalf("Referrer-Policy=%q want strict-origin-when-cross-origin", got)
}
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("X-Frame-Options=%q want DENY", got)
}
hsts := rec.Header().Get("Strict-Transport-Security")
if !strings.Contains(hsts, "max-age=31536000") {
t.Fatalf("HSTS (enabled)=%q missing max-age", hsts)
}
csp := rec.Header().Get("Content-Security-Policy")
if csp == "" || !strings.Contains(csp, "default-src 'self'") || !strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("CSP=%q missing required", csp)
}
pp := rec.Header().Get("Permissions-Policy")
if !strings.Contains(pp, "camera=()") {
t.Fatalf("Permissions-Policy=%q", pp)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/plain" {
t.Fatalf("Content-Type was overwritten? got %q", ct)
}
// HSTS disabled
handlerNoHSTS := SecurityHeadersMiddleware(false, next)
rec2 := httptest.NewRecorder()
handlerNoHSTS.ServeHTTP(rec2, req)
if hsts2 := rec2.Header().Get("Strict-Transport-Security"); hsts2 != "" {
t.Fatalf("HSTS (disabled) should be absent, got %q", hsts2)
}
// other headers still present
if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options (no hsts)=%q want nosniff", got)
}
}
+16 -3
View File
@@ -32,6 +32,19 @@ type rawContentLoad struct {
err error
}
// copyBytes returns a defensive copy so callers cannot mutate the cache's backing
// slice (which would affect concurrent/future hits for the same ID). Cost is
// acceptable: cache is small (default 32 entries) and holds decompressed raw
// content for range requests only.
func copyBytes(b []byte) []byte {
if b == nil {
return nil
}
c := make([]byte, len(b))
copy(c, b)
return c
}
func newRawContentCache(maxEntries int, maxBytes int64) *rawContentCache {
if maxEntries <= 0 {
maxEntries = defaultRawCacheMaxEntries
@@ -53,14 +66,14 @@ func (c *rawContentCache) GetOrLoad(id string, loader func() ([]byte, error)) ([
if elem, ok := c.items[id]; ok {
c.lru.MoveToFront(elem)
item := elem.Value.(*rawContentItem)
data := item.data
data := copyBytes(item.data)
c.mu.Unlock()
return data, nil, true
}
if load, ok := c.inFlight[id]; ok {
c.mu.Unlock()
<-load.wait
return load.data, load.err, false
return copyBytes(load.data), load.err, false
}
load := &rawContentLoad{wait: make(chan struct{})}
@@ -79,7 +92,7 @@ func (c *rawContentCache) GetOrLoad(id string, loader func() ([]byte, error)) ([
c.mu.Unlock()
close(load.wait)
return data, err, false
return copyBytes(data), err, false
}
func (c *rawContentCache) Delete(id string) {
+18 -8
View File
@@ -2,7 +2,6 @@ package storage
import (
"compress/gzip"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
@@ -107,11 +106,11 @@ func newFilesystemStore(dataDir string, defaultTTL time.Duration, encryptionKey
return store, nil
}
func (s *FilesystemStore) Create(ctx context.Context, body io.Reader, contentType string, ttl time.Duration) (Metadata, error) {
return s.CreateWithOriginalName(ctx, body, contentType, "", ttl)
func (s *FilesystemStore) Create(body io.Reader, contentType string, ttl time.Duration) (Metadata, error) {
return s.CreateWithOriginalName(body, contentType, "", ttl)
}
func (s *FilesystemStore) CreateWithOriginalName(ctx context.Context, body io.Reader, contentType string, originalName string, ttl time.Duration) (Metadata, error) {
func (s *FilesystemStore) CreateWithOriginalName(body io.Reader, contentType string, originalName string, ttl time.Duration) (Metadata, error) {
tempFile, err := os.CreateTemp(s.filesDir, "scratch.tmp-*")
if err != nil {
return Metadata{}, fmt.Errorf("create temp file: %w", err)
@@ -264,9 +263,11 @@ func (s *FilesystemStore) Delete(id string) error {
}
s.mu.Unlock()
if err := os.Remove(meta.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("remove scratch file: %w", err)
}
// Best-effort remove of the blob after metadata has been durably deleted from the index.
// On error we tolerate an orphan on-disk blob (no meta entry points to it); reads already
// fail cleanly with ErrNotFound. This prevents Delete from failing due to transient FS issues
// on the blob and avoids meta inconsistency.
_ = os.Remove(meta.FilePath)
return nil
}
@@ -292,11 +293,20 @@ func (s *FilesystemStore) DeleteExpired(now time.Time) (int, error) {
}
s.mu.Unlock()
// Attempt *all* removes (do not abort on first); return count of expired + first remove err if any.
// Callers (cleanup worker) log errs; meta is already gone from index so orphans tolerated.
var firstRemoveErr error
for _, meta := range expired {
if err := os.Remove(meta.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
return 0, fmt.Errorf("remove expired file %s: %w", meta.ID, err)
if firstRemoveErr == nil {
firstRemoveErr = fmt.Errorf("remove expired file %s: %w", meta.ID, err)
}
// continue to clean other entries
}
}
if firstRemoveErr != nil {
return len(expired), firstRemoveErr
}
return len(expired), nil
}
+17 -19
View File
@@ -2,7 +2,6 @@ package storage
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
@@ -44,7 +43,7 @@ func TestCreateStoresGzipAndOpenIsTransparent(t *testing.T) {
}
original := bytes.Repeat([]byte("hello scratch "), 1024)
meta, err := store.Create(context.Background(), bytes.NewReader(original), "text/plain; charset=utf-8", time.Hour)
meta, err := store.Create(bytes.NewReader(original), "text/plain; charset=utf-8", time.Hour)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
@@ -89,7 +88,7 @@ func TestCreateEncryptedStoreDoesNotExposeGzipHeaderOnDisk(t *testing.T) {
}
original := bytes.Repeat([]byte("secret payload "), 1024)
meta, err := store.Create(context.Background(), bytes.NewReader(original), "text/plain", time.Hour)
meta, err := store.Create(bytes.NewReader(original), "text/plain", time.Hour)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
@@ -149,7 +148,7 @@ func TestEncryptedStoreFailsToOpenWithWrongKey(t *testing.T) {
if err != nil {
t.Fatalf("NewEncryptedFilesystemStore(keyA) error = %v", err)
}
_, err = storeA.Create(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
_, err = storeA.Create(bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
@@ -167,7 +166,7 @@ func TestCreateWithOriginalNameNormalizesFilename(t *testing.T) {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
meta, err := store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", " ../../demo.txt ", time.Hour)
meta, err := store.CreateWithOriginalName(bytes.NewReader([]byte("hello")), "text/plain", " ../../demo.txt ", time.Hour)
if err != nil {
t.Fatalf("CreateWithOriginalName() error = %v", err)
}
@@ -196,11 +195,11 @@ func TestCreateWithOriginalNameSameContentGetsDifferentIDs(t *testing.T) {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
first, err := store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("same body")), "text/plain", "one.txt", time.Hour)
first, err := store.CreateWithOriginalName(bytes.NewReader([]byte("same body")), "text/plain", "one.txt", time.Hour)
if err != nil {
t.Fatalf("first CreateWithOriginalName() error = %v", err)
}
second, err := store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("same body")), "text/plain", "two.txt", time.Hour)
second, err := store.CreateWithOriginalName(bytes.NewReader([]byte("same body")), "text/plain", "two.txt", time.Hour)
if err != nil {
t.Fatalf("second CreateWithOriginalName() error = %v", err)
}
@@ -216,7 +215,7 @@ func TestGetDeleteAndNotFound(t *testing.T) {
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
meta, err := store.Create(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
meta, err := store.Create(bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
@@ -241,11 +240,11 @@ func TestDeleteExpired(t *testing.T) {
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
expired, err := store.Create(context.Background(), bytes.NewReader([]byte("expired")), "text/plain", -time.Second)
expired, err := store.Create(bytes.NewReader([]byte("expired")), "text/plain", -time.Second)
if err != nil {
t.Fatalf("Create expired() error = %v", err)
}
active, err := store.Create(context.Background(), bytes.NewReader([]byte("active")), "text/plain", time.Hour)
active, err := store.Create(bytes.NewReader([]byte("active")), "text/plain", time.Hour)
if err != nil {
t.Fatalf("Create active() error = %v", err)
}
@@ -392,7 +391,7 @@ func TestNewFilesystemStoreWithDefaultTTLShiftsExpiriesOnTTLChange(t *testing.T)
t.Fatalf("NewFilesystemStoreWithDefaultTTL() error = %v", err)
}
meta, err := store.Create(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
meta, err := store.Create(bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
@@ -474,7 +473,7 @@ func TestCreateRollbackOnPersistFailure(t *testing.T) {
}
store.index = badIndexDir
if _, err := store.Create(context.Background(), bytes.NewReader([]byte("x")), "text/plain", time.Hour); err == nil {
if _, err := store.Create(bytes.NewReader([]byte("x")), "text/plain", time.Hour); err == nil {
t.Fatalf("Create() error = nil, want persist failure")
}
if len(store.metadata) != 0 {
@@ -489,7 +488,7 @@ func TestCreateReaderFailure(t *testing.T) {
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
if _, err := store.Create(context.Background(), failingReader{}, "text/plain", time.Hour); err == nil {
if _, err := store.Create(failingReader{}, "text/plain", time.Hour); err == nil {
t.Fatalf("Create() error = nil, want reader failure")
}
}
@@ -501,7 +500,7 @@ func TestDeletePersistFailureRestoresEntry(t *testing.T) {
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
meta, err := store.Create(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
meta, err := store.Create(bytes.NewReader([]byte("hello")), "text/plain", time.Hour)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
@@ -547,8 +546,8 @@ func TestDeleteRemoveFailure(t *testing.T) {
if err := store.persistLocked(); err != nil {
t.Fatalf("persistLocked() error = %v", err)
}
if err := store.Delete(id); err == nil {
t.Fatalf("Delete() error = nil, want remove failure")
if err := store.Delete(id); err != nil {
t.Fatalf("Delete() error = %v, want nil (blob remove failure is tolerated after metadata removal; orphan on disk is acceptable since no meta references it)", err)
}
}
@@ -559,7 +558,7 @@ func TestDeleteExpiredPersistFailureRestoresEntries(t *testing.T) {
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
meta, err := store.Create(context.Background(), bytes.NewReader([]byte("expired")), "text/plain", -time.Second)
meta, err := store.Create(bytes.NewReader([]byte("expired")), "text/plain", -time.Second)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
@@ -642,7 +641,7 @@ func TestNewFilesystemStoreFailsToLoadEncryptedIndexWithWrongKey(t *testing.T) {
if err != nil {
t.Fatalf("NewFilesystemStore() error = %v", err)
}
if _, err := store.Create(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", time.Hour); err != nil {
if _, err := store.Create(bytes.NewReader([]byte("hello")), "text/plain", time.Hour); err != nil {
t.Fatalf("Create() error = %v", err)
}
@@ -672,7 +671,6 @@ func TestConcurrentCreateWithOriginalNameProducesUniqueReadableEntries(t *testin
payload := fmt.Sprintf("storage-writer-%03d-%s", i, strings.Repeat("z", i%13))
meta, createErr := store.CreateWithOriginalName(
context.Background(),
strings.NewReader(payload),
"text/plain; charset=utf-8",
fmt.Sprintf("w-%03d.txt", i),