Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90f1cf8bdf | |||
| ad4355df17 |
+23
-1
@@ -1,25 +1,38 @@
|
||||
# Scratchbox env-mode example for `scratchbox server --env`.
|
||||
# Set values here and pass this file with `--env-file .env`.
|
||||
#
|
||||
# The complete list of supported SCRATCHBOX_* env vars is the single source of
|
||||
# truth in internal/config/config.go (var envSuffixes). See the comment on
|
||||
# envSuffixes for maintenance rules when adding new vars.
|
||||
#
|
||||
# IMPORTANT:
|
||||
# - Scratchbox stores its key at $SCRATCHBOX_STORAGE_DATA_DIR/metadata.key.
|
||||
# - Keep that data directory persistent across restarts.
|
||||
|
||||
# server
|
||||
SCRATCHBOX_SERVER_LISTEN_ADDR=:8080
|
||||
SCRATCHBOX_SERVER_READ_HEADER_TIMEOUT=5s
|
||||
# Body/response deadlines. 0 = disabled. Raise or set 0 when LIMITS_MAX_UPLOAD_SIZE is multi-GiB.
|
||||
SCRATCHBOX_SERVER_READ_TIMEOUT=30s
|
||||
SCRATCHBOX_SERVER_WRITE_TIMEOUT=30s
|
||||
SCRATCHBOX_SERVER_IDLE_TIMEOUT=120s
|
||||
SCRATCHBOX_SERVER_SHUTDOWN_TIMEOUT=10s
|
||||
SCRATCHBOX_SERVER_MAX_HEADER_BYTES=1048576
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED=true
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_FILE_PATH=
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_SIZE=100MiB
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_BACKUPS=5
|
||||
|
||||
# limits
|
||||
# When raising MAX_UPLOAD_SIZE to multi-GiB, also set READ/WRITE_TIMEOUT high or 0 (see above).
|
||||
SCRATCHBOX_LIMITS_MAX_UPLOAD_SIZE=100MiB
|
||||
SCRATCHBOX_LIMITS_DEFAULT_TTL=15m
|
||||
# In-memory cache for small /api/raw range hits. Larger scratches stream from disk.
|
||||
SCRATCHBOX_LIMITS_RAW_CACHE_MAX_SIZE=200MiB
|
||||
SCRATCHBOX_LIMITS_RAW_CACHE_MAX_ENTRIES=32
|
||||
|
||||
# storage
|
||||
SCRATCHBOX_STORAGE_DATA_DIR=./data
|
||||
SCRATCHBOX_STORAGE_CLEANUP_INTERVAL=1m
|
||||
|
||||
# security
|
||||
@@ -27,13 +40,22 @@ SCRATCHBOX_STORAGE_CLEANUP_INTERVAL=1m
|
||||
SCRATCHBOX_SECURITY_ALLOWED_IPS=127.0.0.1 # This should be set to your networks CIDR like 192.168.2.0/24 or a particulat IP like 192.168.2.55.
|
||||
SCRATCHBOX_SECURITY_TRUST_PROXY_HEADERS=false
|
||||
# Comma-separated IP/CIDR list.
|
||||
SCRATCHBOX_SECURITY_TRUSTED_PROXY_IPS= # Same as SCRATCHBOX_SECURITY_ALLOWED_IPS just Trusted Proxies you expect clients to come from instead.
|
||||
SCRATCHBOX_SECURITY_TRUSTED_PROXY_IPS= # Same as SCRATCHBOX_SECURITY_ALLOWED_IPS just Trusted Proxies you expect clients to come from instead. Must be *immediate* reverse proxies only; avoid 0/0.
|
||||
|
||||
# Per-client token bucket on UI routes (/, /u, /s/{id}).
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_UI_ENABLED=false
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE=360
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_UI_BURST=120
|
||||
|
||||
# Per-client token bucket on API read routes (GET /api/config, GET /api/scratch/{id}, GET /api/raw/{id}).
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_ENABLED=false
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE=300
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_BURST=100
|
||||
|
||||
# Per-client token bucket on API write route (POST /api/scratch).
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_ENABLED=true
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE=30
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_BURST=10
|
||||
|
||||
# Enable Strict-Transport-Security (HSTS) header (recommended for public TLS deploys).
|
||||
SCRATCHBOX_SECURITY_HSTS_ENABLED=true
|
||||
|
||||
@@ -20,6 +20,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Verify Go modules
|
||||
run: go mod verify
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -52,6 +55,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Verify Go modules
|
||||
run: go mod verify
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@@ -55,6 +55,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Verify Go modules
|
||||
run: go mod verify
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
+3
-1
@@ -12,4 +12,6 @@
|
||||
/web/ui/node_modules/
|
||||
|
||||
# Frontend build artifacts
|
||||
/web/static/
|
||||
/web/static/
|
||||
# Local/large test fixtures (do not commit)
|
||||
/testdata/*.zip
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.25.4-alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
|
||||
@@ -64,13 +64,14 @@ Storage encryption key behavior:
|
||||
- On subsequent startups, Scratchbox reuses the same key file from that data directory.
|
||||
- Keep `storage.data_dir` persistent; losing or replacing `metadata.key` makes existing scratches unreadable.
|
||||
|
||||
Environment variables supported by `scratchbox server --env`:
|
||||
Environment variables supported by `scratchbox server --env` (note: when `--env` is used, the variables and their values are printed to stdout on startup for diagnostics, with values of IP allow/trusted lists redacted). The canonical list of supported vars is envSuffixes in internal/config/config.go — see the comment there for how to keep this documentation in sync when adding new configuration.
|
||||
|
||||
- `SCRATCHBOX_SERVER_LISTEN_ADDR`
|
||||
- `SCRATCHBOX_SERVER_READ_HEADER_TIMEOUT`
|
||||
- `SCRATCHBOX_SERVER_READ_TIMEOUT`
|
||||
- `SCRATCHBOX_SERVER_WRITE_TIMEOUT`
|
||||
- `SCRATCHBOX_SERVER_IDLE_TIMEOUT`
|
||||
- `SCRATCHBOX_SERVER_SHUTDOWN_TIMEOUT`
|
||||
- `SCRATCHBOX_SERVER_MAX_HEADER_BYTES`
|
||||
- `SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED`
|
||||
- `SCRATCHBOX_SERVER_ACCESS_LOG_FILE_PATH` (forced empty by Docker image)
|
||||
@@ -94,8 +95,9 @@ Environment variables supported by `scratchbox server --env`:
|
||||
- `SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_ENABLED`
|
||||
- `SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE`
|
||||
- `SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_BURST`
|
||||
- `SCRATCHBOX_SECURITY_HSTS_ENABLED`
|
||||
|
||||
Example `.env` for Docker:
|
||||
Example `.env` for Docker (must be kept in sync with envSuffixes in internal/config/config.go):
|
||||
|
||||
```dotenv
|
||||
# server
|
||||
@@ -104,8 +106,12 @@ SCRATCHBOX_SERVER_READ_HEADER_TIMEOUT=5s
|
||||
SCRATCHBOX_SERVER_READ_TIMEOUT=30s
|
||||
SCRATCHBOX_SERVER_WRITE_TIMEOUT=30s
|
||||
SCRATCHBOX_SERVER_IDLE_TIMEOUT=120s
|
||||
SCRATCHBOX_SERVER_SHUTDOWN_TIMEOUT=10s
|
||||
SCRATCHBOX_SERVER_MAX_HEADER_BYTES=1048576
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_ENABLED=true
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_FILE_PATH=
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_SIZE=100MiB
|
||||
SCRATCHBOX_SERVER_ACCESS_LOG_MAX_BACKUPS=5
|
||||
|
||||
# limits
|
||||
SCRATCHBOX_LIMITS_MAX_UPLOAD_SIZE=100MiB
|
||||
@@ -130,6 +136,7 @@ SCRATCHBOX_SECURITY_RATE_LIMIT_API_READ_BURST=100
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_ENABLED=true
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE=30
|
||||
SCRATCHBOX_SECURITY_RATE_LIMIT_API_WRITE_BURST=10
|
||||
SCRATCHBOX_SECURITY_HSTS_ENABLED=true
|
||||
```
|
||||
|
||||
## Release Artifacts
|
||||
@@ -176,15 +183,20 @@ All configuration is YAML.
|
||||
- `read_header_timeout`: maximum time for reading request headers.
|
||||
- Must be `> 0`
|
||||
- Default: `5s`
|
||||
- `read_timeout`: maximum time for reading the full request.
|
||||
- Must be `> 0`
|
||||
- `read_timeout`: maximum time for reading the full request (including body).
|
||||
- Must be `>= 0`. `0` disables the deadline (same as Go `http.Server`).
|
||||
- Raise this (or set `0`) when `limits.max_upload_size` is large or clients are slow: a 30s body timeout cannot complete multi-GiB uploads.
|
||||
- Default: `30s`
|
||||
- `write_timeout`: maximum time allowed for writing the response.
|
||||
- Must be `> 0`
|
||||
- `write_timeout`: maximum time allowed for writing the response (also bounds slow request handling after headers are read).
|
||||
- Must be `>= 0`. `0` disables the deadline.
|
||||
- Raise this (or set `0`) for large uploads/downloads; after request headers are read the write deadline covers the rest of the handler.
|
||||
- Default: `30s`
|
||||
- `idle_timeout`: maximum keep-alive idle time between requests.
|
||||
- Must be `> 0`
|
||||
- Default: `120s`
|
||||
- `shutdown_timeout`: graceful shutdown timeout for in-flight requests on SIGTERM/INT.
|
||||
- Must be `> 0`
|
||||
- Default: `10s`
|
||||
- `max_header_bytes`: max HTTP header size in bytes.
|
||||
- Must be `> 0`
|
||||
- Default: `1048576` (1 MiB)
|
||||
@@ -243,6 +255,7 @@ All configuration is YAML.
|
||||
- Keep `false` unless behind a trusted reverse proxy that sets these headers.
|
||||
- `trusted_proxy_ips`: list of reverse-proxy IPs/CIDRs permitted to supply forwarded headers.
|
||||
- Required when `trust_proxy_headers: true`
|
||||
- **Must be the *immediate* reverse proxies only (e.g. the nginx/caddy in front of this service); never use 0.0.0.0/0 or broad ranges unless you fully control the entire path.**
|
||||
- Examples: `127.0.0.1`, `10.0.0.0/8`
|
||||
- `rate_limit_ui`: per-IP token-bucket for UI routes (`GET /`, `GET /u`, `GET /s/{id}`).
|
||||
- `enabled` default: `false`
|
||||
@@ -256,6 +269,7 @@ All configuration is YAML.
|
||||
- `enabled` default: `true`
|
||||
- `requests_per_minute` must be `> 0` (default `30`)
|
||||
- `burst` must be `> 0` (default `10`)
|
||||
- `hsts_enabled`: enable HSTS header (default: `true`; for public proxy TLS setups; set `false` for local HTTP-only per LAN notes).
|
||||
|
||||
## Security Posture
|
||||
|
||||
@@ -295,3 +309,10 @@ Scratchbox storage is designed for a single process instance per `storage.data_d
|
||||
- Metadata/index writes are process-local and are not coordinated with cross-process locking.
|
||||
- Scratch payload files and metadata index are encrypted at rest.
|
||||
- Public scratch IDs are short random aliases; stored blobs still use SHA-256-derived internal blob IDs on disk.
|
||||
|
||||
Operational internals (not tunable; see source consts):
|
||||
- Public IDs: 12 alphanum chars (crypto/rand), up to 8 attempts to reserve unique.
|
||||
- Encrypted chunks: 64 KiB plaintext (SBX1 magic + per-chunk nonce prefix+counter for AES-256-GCM).
|
||||
- Index: full random nonce per persist (SMD1).
|
||||
- Background expiry + startup reconcile walk the in-memory map under exclusive lock (plus optional persist); practical cardinality limited by default 15m TTL + 100MiB uploads (low hundreds of live entries expected).
|
||||
- Raw /api/raw cache: small LRU (default 32 entries, total bytes cap) with coalescing; serves decompressed content.
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# Scratchbox Website Audit Report
|
||||
|
||||
**URL audited:** https://scratchbox.s1d3sw1ped.com/
|
||||
**Date:** 2026-06-01
|
||||
**Auditor:** Grok (via Playwright browser automation + direct HTTP/API testing + codebase review)
|
||||
**Project commit / state:** Current source in `/fast/projects/golang/scratchbox`
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Scratchbox is a deliberately minimal, unauthenticated, self-hosted temporary file sharing service. The live deployment matches the project exactly and performs very well for its stated goals.
|
||||
|
||||
**Strengths**
|
||||
- Extremely lightweight (≈ 45 KB total assets).
|
||||
- Excellent core functionality and HTTP semantics (201 Created, 410 Gone for expired).
|
||||
- Clean error UX and client-side routing.
|
||||
- No trackers, no external dependencies, very fast responses.
|
||||
- Backend Go code is focused and easy to reason about.
|
||||
- Direct API (`POST /api/scratch`) works reliably and returns useful JSON.
|
||||
|
||||
**Main Issues (prioritized)**
|
||||
1. **Security headers completely absent** (CSP, HSTS, X-Content-Type-Options, etc.). High priority for any public or semi-public deployment.
|
||||
2. **Poor SEO / social sharing metadata.** Every page (including `/s/{id}` share links) has only a static `<title>Scratchbox</title>`. No Open Graph, no description, no per-scratch titles. Share previews look bad on Slack, Discord, Twitter, etc.
|
||||
3. **Missing favicon** (causes repeated 404 noise).
|
||||
4. Minor: 404/410 routes return the full SPA shell HTML (acceptable for SPA but not ideal for `robots.txt`, `sitemap.xml`, etc.).
|
||||
5. Minor: Console errors logged for expected 410 fetches on expired scratches.
|
||||
|
||||
The frontend is a Vite + Svelte SPA. The Go backend serves a single static `index.html` shell for all UI routes (`/`, `/u`, `/s/{id}`, 404s, and 410s) via `serveSPAWithStatus`.
|
||||
|
||||
---
|
||||
|
||||
## Methodology
|
||||
|
||||
- Full browser automation with Playwright (navigation, snapshots, screenshots, console, network, clicks, viewport resize to mobile 390×844).
|
||||
- Direct HTTP inspection (headers, raw content, timing).
|
||||
- Successful end-to-end upload test via both the discovered API (`/api/scratch`) and UI flows.
|
||||
- Codebase review of relevant files (`web/static/index.html`, `web/ui/src/App.svelte`, `internal/http/handlers.go`, etc.).
|
||||
- One temporary test scratch was created (ID `OvhWwqqXnyA6`, text file, auto-expired after 15m).
|
||||
|
||||
---
|
||||
|
||||
## Technical Inventory (Live + Source)
|
||||
|
||||
| Item | Value / Location |
|
||||
|-------------------------|-------------------------------------------------------|
|
||||
| HTML shell | `web/static/index.html` (embedded) + `web/ui/index.html` (source) |
|
||||
| Frontend source | `web/ui/src/App.svelte`, `web/ui/src/main.js`, `web/ui/vite.config.js` |
|
||||
| Built assets | `web/static/assets/index-*.js` (43.5 KB), `*.css` (2.1 KB) |
|
||||
| Backend serving | `internal/http/handlers.go` (`serveSPAWithStatus`, `Routes`) |
|
||||
| Asset embedding | `web/assets.go` (`//go:embed static`) |
|
||||
| API routes | `POST /api/scratch`, `GET /api/scratch/{id}`, `GET /api/raw/{id}`, `GET /s/{id}`, `GET /api/config` |
|
||||
| Config exposure | `/api/config` returns `max_upload_size_bytes`, `default_ttl`, `upload_allowed` |
|
||||
| No security headers | Confirmed in `handlers.go` and `middleware.go` |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Findings
|
||||
|
||||
### 1. Security Headers (High Priority)
|
||||
|
||||
**Finding:** No security-related response headers are set for HTML, API, or asset responses.
|
||||
|
||||
Observed headers (typical):
|
||||
```
|
||||
server: openresty
|
||||
content-type: text/html; charset=utf-8
|
||||
x-served-by: scratchbox.s1d3sw1ped.com
|
||||
```
|
||||
|
||||
Missing (recommended minimum):
|
||||
- `Content-Security-Policy`
|
||||
- `Strict-Transport-Security` (HSTS)
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `Referrer-Policy`
|
||||
- `X-Frame-Options` / CSP frame-ancestors
|
||||
- `Permissions-Policy`
|
||||
|
||||
**Impact:** Higher risk of XSS, clickjacking, MIME sniffing, etc., especially since the site accepts and serves user-provided file content (even temporarily).
|
||||
|
||||
**Files to change:**
|
||||
- `internal/http/handlers.go` (in `serveSPAWithStatus`, `getUIConfig`, `rawScratch`, etc.)
|
||||
- `internal/http/middleware.go` (good place for a `SecurityHeadersMiddleware`)
|
||||
|
||||
**Recommendation (minimal):** Add a small middleware that sets safe defaults. Example starting point:
|
||||
|
||||
```go
|
||||
func SecurityHeadersMiddleware(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")
|
||||
// Add CSP, HSTS (only over HTTPS), etc. as needed
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Register it early in `Routes()`.
|
||||
|
||||
See also README.md "Public Deployment Notes" — this audit reinforces those recommendations.
|
||||
|
||||
### 2. SEO & Social Metadata (High Priority for Share Links)
|
||||
|
||||
**Finding:** The `<head>` in the served shell is static and minimal:
|
||||
|
||||
```html
|
||||
<title>Scratchbox</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- no description, no og:*, no twitter:*, no canonical -->
|
||||
```
|
||||
|
||||
- `/s/{id}` pages always show the generic title.
|
||||
- No per-scratch `<title>` or `og:title` containing the filename.
|
||||
- No `og:description` or image (even a simple logo would help).
|
||||
|
||||
**Impact:** When users share `/s/xxx` links, previews are poor or missing.
|
||||
|
||||
**Files:**
|
||||
- `web/static/index.html` (served shell)
|
||||
- `web/ui/index.html` (Vite source template)
|
||||
- `web/ui/src/App.svelte` — currently no `<svelte:head>` usage for dynamic updates.
|
||||
|
||||
**Recommendations (in priority order):**
|
||||
1. Add basic static meta to both `index.html` files (description + og tags for the generic case).
|
||||
2. In `App.svelte`, use `<svelte:head>` inside the `{:else if routeMode === "view"}` branch to set a dynamic `<title>` and `og:*` tags based on `viewMeta.filename`, size, etc.
|
||||
3. For best results, consider lightweight server-side injection in `scratchPage` / `serveSPAWithStatus` (parse the ID and inject meta before serving the shell). This gives crawlers and social bots good data even with JS disabled.
|
||||
|
||||
Example dynamic title idea: `"{filename} • Scratchbox"` (with fallback).
|
||||
|
||||
### 3. Favicon (Low–Medium)
|
||||
|
||||
**Finding:** Repeated 404s for `/favicon.ico` in every browser session / page load.
|
||||
|
||||
**Files:**
|
||||
- `web/static/index.html` (add `<link rel="icon" ...>`)
|
||||
- `web/ui/index.html` (same)
|
||||
- Add a real `favicon.ico` (or SVG) under `web/static/` and ensure it's embedded.
|
||||
|
||||
### 4. SPA Shell for Non-HTML 404s
|
||||
|
||||
**Finding:** `robots.txt`, `sitemap.xml`, `.well-known/*`, and invalid asset paths all return the full 394-byte HTML shell with a 404 (or 410) status.
|
||||
|
||||
This is standard SPA behavior but not ideal for well-known paths.
|
||||
|
||||
**Current code:** `notFoundPage` → `serveSPAWithStatus(404)` for everything under `GET /{path...}`.
|
||||
|
||||
**Recommendation:** Add early special cases in the mux for common paths:
|
||||
|
||||
```go
|
||||
mux.Handle("GET /robots.txt", http.HandlerFunc(func(w, r) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
// or serve a static disallow file
|
||||
}))
|
||||
```
|
||||
|
||||
Or serve small static files for these paths from the embedded FS.
|
||||
|
||||
### 5. Frontend Form & Upload Details
|
||||
|
||||
In the current `App.svelte` source (as of audit):
|
||||
|
||||
- The form uses pure JS (`onsubmit={onSubmit}`) — no `method`/`action` attributes. (The `method="get"` seen in the live DOM during audit was likely a browser default or inspection artifact; source is clean.)
|
||||
- File input is a standard `<input type="file" id="file" name="file">` behind a `<label>`.
|
||||
- Good validation (size check client-side + server `MaxBytesReader`).
|
||||
- Error messages are clear and injected into the DOM.
|
||||
|
||||
**Minor observation:** No visible drag-and-drop zone (the UI is intentionally minimal). Adding one would be a nice UX improvement but is out of scope for a minimal project unless requested.
|
||||
|
||||
### 6. Other Observations
|
||||
|
||||
- **Performance:** Outstanding. 4 requests for a full UI load.
|
||||
- **Mobile:** Tested at 390×844 — layout holds up well due to simplicity.
|
||||
- **Accessibility:** Basic roles and labels are present (`aria-label="Primary"`, button text, etc.). The file input could benefit from better visual association, but it's functional. A full axe-core or WAVE scan is recommended if public use grows.
|
||||
- **Console noise:** The only errors are the favicon 404 and the expected 410 fetch errors when viewing expired scratches (the app correctly catches 404/410 and shows the friendly UI). These could be silenced with `{ok: response.ok}` style guards or by not logging at error level for known cases.
|
||||
- **Rate limiting & allowlisting:** Already implemented and configurable (see README). Good.
|
||||
- **One test scratch** was created during audit via the public API and viewed successfully. It auto-expired as designed.
|
||||
|
||||
---
|
||||
|
||||
## Actionable Recommendations (Minimal Changes)
|
||||
|
||||
| Priority | Change | Files | Effort |
|
||||
|----------|--------|-------|--------|
|
||||
| High | Add security headers middleware | `internal/http/middleware.go` + wire in `handlers.go:Routes()` | Low |
|
||||
| High | Add basic `<meta>` + OG tags to shell | `web/static/index.html` + `web/ui/index.html` | Low |
|
||||
| High | Dynamic title/meta for `/s/{id}` pages | `web/ui/src/App.svelte` (add `<svelte:head>`) | Low–Medium |
|
||||
| Medium | Add real favicon + link tag | `web/static/` + the two `index.html` files | Low |
|
||||
| Low | Special-case `robots.txt` etc. | `internal/http/handlers.go` | Low |
|
||||
| Low | Reduce console.error noise on 410s | `web/ui/src/App.svelte` (in `loadScratchView`) | Low |
|
||||
|
||||
After any frontend HTML changes, run `make build` (or the equivalent Vite step) to update the embedded assets.
|
||||
|
||||
---
|
||||
|
||||
## Strengths Worth Preserving
|
||||
|
||||
- Intentional minimalism and "no accounts, no friction" philosophy.
|
||||
- Transparent storage (gzip + encryption handled server-side).
|
||||
- Excellent separation of concerns in Go code.
|
||||
- Strong test coverage in `internal/http/` and storage.
|
||||
- Clear documentation in `README.md` and `AGENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Raw Data from Audit
|
||||
|
||||
- API config response: `{"default_ttl":"15m","max_upload_size_bytes":104857600,"upload_allowed":true}`
|
||||
- Successful upload JSON (example):
|
||||
```json
|
||||
{
|
||||
"id": "OvhWwqqXnyA6",
|
||||
"view_url": "/s/OvhWwqqXnyA6",
|
||||
"raw_url": "/api/raw/OvhWwqqXnyA6",
|
||||
"expires_at": "2026-06-01T22:31:24.662891754Z",
|
||||
...
|
||||
}
|
||||
```
|
||||
- Raw endpoint correctly returns original bytes + `Content-Disposition: attachment; filename=...`
|
||||
- All screenshots and full Playwright traces were captured during the session but are not embedded here (available in the auditor's session artifacts if needed).
|
||||
|
||||
---
|
||||
|
||||
**End of report.**
|
||||
|
||||
This audit was performed with full authorization from the site owner. The temporary test scratch created during testing has long since expired.
|
||||
|
||||
If you would like me to implement any of the recommendations (as minimal, focused patches per `AGENTS.md`), provide the exact priorities and I can prepare the diffs.
|
||||
@@ -111,14 +111,18 @@ func (w *rollingFileWriter) rotateLocked() error {
|
||||
}
|
||||
return fmt.Errorf("stat rotated file %s: %w", from, err)
|
||||
}
|
||||
_ = os.Remove(to)
|
||||
if err := os.Remove(to); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove target backup slot %s: %w", to, err)
|
||||
}
|
||||
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.Remove(firstBackup); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove first backup %s: %w", firstBackup, err)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -135,6 +135,7 @@ func TestMainProcessEnvSuccess(t *testing.T) {
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_WANT_MAIN_HELPER=1",
|
||||
"MAIN_HELPER_MODE=env-success",
|
||||
// These are from the canonical envSuffixes list (internal/config/config.go)
|
||||
"SCRATCHBOX_SERVER_LISTEN_ADDR=127.0.0.1:0",
|
||||
"SCRATCHBOX_STORAGE_DATA_DIR="+filepath.Join(tmp, "data"),
|
||||
"SCRATCHBOX_SECURITY_ALLOWED_IPS=",
|
||||
@@ -347,6 +348,16 @@ func TestGenerateConfigCommandWritesDefaultYAML(t *testing.T) {
|
||||
if !cfg.Security.RateLimitAPIWrite.Enabled {
|
||||
t.Fatalf("security.rate_limit_api_write.enabled = %v, want true", cfg.Security.RateLimitAPIWrite.Enabled)
|
||||
}
|
||||
// Additional assertions act as golden checks against generate drift in defaults/comments/struct.
|
||||
if cfg.Storage.CleanupInterval != "1m" {
|
||||
t.Fatalf("storage.cleanup_interval = %q, want %q", cfg.Storage.CleanupInterval, "1m")
|
||||
}
|
||||
if !cfg.Security.HSTSEnabled {
|
||||
t.Fatalf("security.hsts_enabled = %v, want true", cfg.Security.HSTSEnabled)
|
||||
}
|
||||
if cfg.Limits.RawCacheMaxEntries != 32 {
|
||||
t.Fatalf("limits.raw_cache_max_entries = %d, want 32", cfg.Limits.RawCacheMaxEntries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigCommandRejectsUnexpectedArgs(t *testing.T) {
|
||||
@@ -437,6 +448,9 @@ security:
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load() error = %v", err)
|
||||
}
|
||||
if err := cfg.PrepareDataDir(); err != nil {
|
||||
t.Fatalf("config.PrepareDataDir() error = %v", err)
|
||||
}
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
store, err := storage.NewFilesystemStore(cfg.Storage.DataDir, cfg.Storage.EncryptionKeyBytes)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -35,7 +34,7 @@ func newServerCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&configPath, "config", "c", "", "path to config file")
|
||||
cmd.Flags().BoolVar(&useEnv, "env", false, "load config from SCRATCHBOX_* environment variables")
|
||||
cmd.Flags().BoolVar(&useEnv, "env", false, "load config from SCRATCHBOX_* environment variables (see envSuffixes in internal/config/config.go)")
|
||||
cmd.MarkFlagsMutuallyExclusive("config", "env")
|
||||
return cmd
|
||||
}
|
||||
@@ -66,6 +65,12 @@ func run(configPath string, useEnv bool) error {
|
||||
return fmt.Errorf("config load failed: %w", err)
|
||||
}
|
||||
|
||||
// Prepare data dir + key (side effects) after pure Validate inside Load*/Default.
|
||||
// This addresses the layering issue where Validate used to mutate FS.
|
||||
if err := cfg.PrepareDataDir(); err != nil {
|
||||
return fmt.Errorf("prepare storage data dir failed: %w", err)
|
||||
}
|
||||
|
||||
logger := log.New(os.Stdout, "[scratchbox] ", log.LstdFlags|log.LUTC)
|
||||
accessWriter, closeAccessWriter, err := newAccessLogWriter(cfg)
|
||||
if err != nil {
|
||||
@@ -114,7 +119,7 @@ func run(configPath string, useEnv bool) error {
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeoutDur)
|
||||
defer shutdownCancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Printf("http shutdown failed: %v", err)
|
||||
@@ -129,6 +134,8 @@ func run(configPath string, useEnv bool) error {
|
||||
}
|
||||
|
||||
func printEnvStartup() int {
|
||||
// All SCRATCHBOX_* variables are defined by envSuffixes (single source of truth)
|
||||
// in internal/config/config.go. See the comment there for update rules.
|
||||
entries := make([]string, 0)
|
||||
for _, item := range os.Environ() {
|
||||
if strings.HasPrefix(item, "SCRATCHBOX_") {
|
||||
@@ -143,7 +150,15 @@ func printEnvStartup() int {
|
||||
return 0
|
||||
}
|
||||
for _, item := range entries {
|
||||
fmt.Fprintf(os.Stdout, "[scratchbox] %s\n", item)
|
||||
printItem := item
|
||||
// Redact values for IP list config (security sensitive for allowlist/rate/proxy spoof surface).
|
||||
// Rates and other are left as-is (numeric, not secret).
|
||||
if strings.Contains(item, "ALLOWED_IPS=") || strings.Contains(item, "TRUSTED_PROXY_IPS=") {
|
||||
if eq := strings.IndexByte(item, '='); eq >= 0 {
|
||||
printItem = item[:eq+1] + "*** (redacted)"
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "[scratchbox] %s\n", printItem)
|
||||
}
|
||||
return len(entries)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# This script forces certain SCRATCHBOX_* vars for Docker.
|
||||
# The full list of supported env vars (single source of truth) is in
|
||||
# internal/config/config.go:envSuffixes . Update that list + docs when changing env support.
|
||||
|
||||
# Container-enforced data directory.
|
||||
SCRATCHBOX_STORAGE_DATA_DIR=/data
|
||||
export SCRATCHBOX_STORAGE_DATA_DIR
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+126
-20
@@ -22,6 +22,7 @@ const (
|
||||
defaultReadTimeout = "30s"
|
||||
defaultWriteTimeout = "30s"
|
||||
defaultIdleTimeout = "120s"
|
||||
defaultShutdownTimeout = "10s"
|
||||
defaultMaxHeaderBytes = 1 << 20
|
||||
defaultAccessLogEnabled = true
|
||||
defaultAccessLogMaxSize = "100MiB"
|
||||
@@ -42,30 +43,89 @@ const (
|
||||
defaultAPIReadRateLimitBurst = 100
|
||||
defaultAPIWriteRequestsPerMinute = 30
|
||||
defaultAPIWriteRateLimitBurst = 10
|
||||
defaultHSTSEnabled = true
|
||||
maxDefaultTTLLimit = 24 * time.Hour
|
||||
minCleanupInterval = 10 * time.Second
|
||||
envPrefix = "SCRATCHBOX_"
|
||||
)
|
||||
|
||||
// envSuffixes is the single source of truth for every SCRATCHBOX_* environment
|
||||
// variable suffix supported by applyEnvOverrides (without the "SCRATCHBOX_" prefix).
|
||||
//
|
||||
// Usage sites that hardcode or document these vars MUST have a comment pointing here:
|
||||
// - applyEnvOverrides (this file)
|
||||
// - printEnvStartup + flag docs in cmd/scratchbox/server.go
|
||||
// - docker-entrypoint.sh
|
||||
// - .env.example
|
||||
// - README.md (supported list + example block)
|
||||
// - tests that setenv them (e.g. config_test.go, main_test.go)
|
||||
//
|
||||
// When you add, remove, or rename support in applyEnvOverrides (or the corresponding
|
||||
// struct fields), you MUST:
|
||||
// 1. Update this list.
|
||||
// 2. Add/update the pointer comment at every usage site.
|
||||
// 3. Update .env.example with the new var (plus a sensible default + comment).
|
||||
// 4. Update the "Environment variables supported by..." list in README.md.
|
||||
// 5. Update the example .env block in README.md.
|
||||
//
|
||||
// A test in config_test.go will fail if .env.example or README.md are missing any
|
||||
// entry from this list. This prevents the documentation drift that has happened
|
||||
// in the past when new config fields (e.g. shutdown_timeout, access_log details)
|
||||
// were added.
|
||||
var envSuffixes = []string{
|
||||
"SERVER_LISTEN_ADDR",
|
||||
"SERVER_READ_HEADER_TIMEOUT",
|
||||
"SERVER_READ_TIMEOUT",
|
||||
"SERVER_WRITE_TIMEOUT",
|
||||
"SERVER_IDLE_TIMEOUT",
|
||||
"SERVER_SHUTDOWN_TIMEOUT",
|
||||
"SERVER_MAX_HEADER_BYTES",
|
||||
"SERVER_ACCESS_LOG_ENABLED",
|
||||
"SERVER_ACCESS_LOG_FILE_PATH",
|
||||
"SERVER_ACCESS_LOG_MAX_SIZE",
|
||||
"SERVER_ACCESS_LOG_MAX_BACKUPS",
|
||||
"LIMITS_MAX_UPLOAD_SIZE",
|
||||
"LIMITS_DEFAULT_TTL",
|
||||
"LIMITS_RAW_CACHE_MAX_SIZE",
|
||||
"LIMITS_RAW_CACHE_MAX_ENTRIES",
|
||||
"STORAGE_DATA_DIR",
|
||||
"STORAGE_CLEANUP_INTERVAL",
|
||||
"SECURITY_ALLOWED_IPS",
|
||||
"SECURITY_TRUST_PROXY_HEADERS",
|
||||
"SECURITY_TRUSTED_PROXY_IPS",
|
||||
"SECURITY_RATE_LIMIT_UI_ENABLED",
|
||||
"SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE",
|
||||
"SECURITY_RATE_LIMIT_UI_BURST",
|
||||
"SECURITY_RATE_LIMIT_API_READ_ENABLED",
|
||||
"SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE",
|
||||
"SECURITY_RATE_LIMIT_API_READ_BURST",
|
||||
"SECURITY_RATE_LIMIT_API_WRITE_ENABLED",
|
||||
"SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE",
|
||||
"SECURITY_RATE_LIMIT_API_WRITE_BURST",
|
||||
"SECURITY_HSTS_ENABLED",
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server" comment:"HTTP listener settings."`
|
||||
Limits LimitsConfig `yaml:"limits" comment:"Upload, expiration, and raw cache defaults."`
|
||||
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 {
|
||||
ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."`
|
||||
ReadHeaderTimeout string `yaml:"read_header_timeout" comment:"Maximum duration for reading request headers (for slowloris protection). Must be > 0."`
|
||||
ReadTimeout string `yaml:"read_timeout" comment:"Maximum duration for reading the full request, including body. Must be > 0."`
|
||||
WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response. Must be > 0."`
|
||||
ReadTimeout string `yaml:"read_timeout" comment:"Maximum duration for reading the full request, including body. Use 0 to disable (recommended when limits.max_upload_size is multi-GiB or clients are slow). Must be >= 0."`
|
||||
WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response (also bounds slow handlers after headers are read). Use 0 to disable (recommended for large uploads/downloads). Must be >= 0."`
|
||||
IdleTimeout string `yaml:"idle_timeout" comment:"Maximum amount of time to wait for the next request when keep-alives are enabled. Must be > 0."`
|
||||
ShutdownTimeout string `yaml:"shutdown_timeout" comment:"Graceful shutdown timeout for the HTTP server. Must be > 0."`
|
||||
MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."`
|
||||
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 +156,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 +211,7 @@ func defaults() Config {
|
||||
ReadTimeout: defaultReadTimeout,
|
||||
WriteTimeout: defaultWriteTimeout,
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
ShutdownTimeout: defaultShutdownTimeout,
|
||||
MaxHeaderBytes: defaultMaxHeaderBytes,
|
||||
AccessLog: AccessLogConfig{
|
||||
Enabled: defaultAccessLogEnabled,
|
||||
@@ -186,6 +248,7 @@ func defaults() Config {
|
||||
RequestsPerMinute: defaultAPIWriteRequestsPerMinute,
|
||||
Burst: defaultAPIWriteRateLimitBurst,
|
||||
},
|
||||
HSTSEnabled: defaultHSTSEnabled,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -207,8 +270,9 @@ func (c *Config) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("server.read_timeout invalid: %w", err)
|
||||
}
|
||||
if readTimeout <= 0 {
|
||||
return errors.New("server.read_timeout must be > 0")
|
||||
// 0 means no timeout (matches net/http.Server.ReadTimeout). Negative is invalid.
|
||||
if readTimeout < 0 {
|
||||
return errors.New("server.read_timeout must be >= 0")
|
||||
}
|
||||
c.Server.ReadTimeoutDur = readTimeout
|
||||
|
||||
@@ -216,8 +280,9 @@ func (c *Config) Validate() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("server.write_timeout invalid: %w", err)
|
||||
}
|
||||
if writeTimeout <= 0 {
|
||||
return errors.New("server.write_timeout must be > 0")
|
||||
// 0 means no timeout (matches net/http.Server.WriteTimeout). Negative is invalid.
|
||||
if writeTimeout < 0 {
|
||||
return errors.New("server.write_timeout must be >= 0")
|
||||
}
|
||||
c.Server.WriteTimeoutDur = writeTimeout
|
||||
|
||||
@@ -230,6 +295,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 +352,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 +392,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 +504,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
|
||||
@@ -492,6 +588,9 @@ func decodeEncryptionKey(raw string) ([]byte, error) {
|
||||
return encKey, nil
|
||||
}
|
||||
|
||||
// applyEnvOverrides applies all supported SCRATCHBOX_* vars.
|
||||
// The canonical list of supported suffixes is in envSuffixes (defined earlier in this file).
|
||||
// See the comment on envSuffixes for the full maintenance rules (update list + .env.example + README).
|
||||
func (c *Config) applyEnvOverrides(prefix string) error {
|
||||
if err := applyStringEnv(prefix+"SERVER_LISTEN_ADDR", &c.Server.ListenAddr); err != nil {
|
||||
return err
|
||||
@@ -508,6 +607,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 +682,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,30 @@ 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 TestValidateAllowsZeroReadAndWriteTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaults()
|
||||
cfg.Storage.DataDir = t.TempDir()
|
||||
// 0 disables the deadline (net/http semantics). Needed for multi-GiB uploads
|
||||
// where a fixed 30s body/response timeout cannot work.
|
||||
cfg.Server.ReadTimeout = "0s"
|
||||
cfg.Server.WriteTimeout = "0"
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() returned error: %v", err)
|
||||
}
|
||||
if cfg.Server.ReadTimeoutDur != 0 {
|
||||
t.Fatalf("ReadTimeoutDur = %s, want 0", cfg.Server.ReadTimeoutDur)
|
||||
}
|
||||
if cfg.Server.WriteTimeoutDur != 0 {
|
||||
t.Fatalf("WriteTimeoutDur = %s, want 0", cfg.Server.WriteTimeoutDur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesConfiguredFields(t *testing.T) {
|
||||
@@ -116,6 +146,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 +156,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 +175,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 +223,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))
|
||||
}
|
||||
@@ -230,6 +270,13 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
|
||||
},
|
||||
wantErr: "server.read_timeout",
|
||||
},
|
||||
{
|
||||
name: "negative read timeout",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Server.ReadTimeout = "-1s"
|
||||
},
|
||||
wantErr: "server.read_timeout",
|
||||
},
|
||||
{
|
||||
name: "invalid write timeout",
|
||||
mutate: func(cfg *Config) {
|
||||
@@ -237,6 +284,13 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
|
||||
},
|
||||
wantErr: "server.write_timeout",
|
||||
},
|
||||
{
|
||||
name: "negative write timeout",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Server.WriteTimeout = "-1s"
|
||||
},
|
||||
wantErr: "server.write_timeout",
|
||||
},
|
||||
{
|
||||
name: "invalid idle timeout",
|
||||
mutate: func(cfg *Config) {
|
||||
@@ -244,6 +298,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 +493,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 +536,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 +554,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 +581,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 +606,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 +654,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 +702,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 {
|
||||
@@ -661,3 +748,46 @@ func TestGenerateEncryptionKey(t *testing.T) {
|
||||
t.Fatalf("decoded key length = %d, want 32", len(b1))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvDocumentationIsUpToDate ensures .env.example and the env var
|
||||
// documentation + example in README.md stay in sync with the env vars
|
||||
// actually supported by the code (envSuffixes in config.go).
|
||||
// See the comment on envSuffixes for the list of places that must point back to it.
|
||||
//
|
||||
// This is the guard against the recurring "example env and readme have
|
||||
// become out of sync" problem. When you add a new field that is loaded via
|
||||
// applyEnvOverrides, you must:
|
||||
// - add its suffix to envSuffixes
|
||||
// - add a line (with example value + comment) to .env.example
|
||||
// - add it to the list and the ```dotenv example in README.md
|
||||
//
|
||||
// The test will fail loudly until the docs are updated.
|
||||
func TestEnvDocumentationIsUpToDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Check .env.example (at repo root). Note: go test for this package
|
||||
// runs with cwd set to the package directory (internal/config), so we
|
||||
// need to go up two levels.
|
||||
envExample, err := os.ReadFile("../../.env.example")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read .env.example: %v", err)
|
||||
}
|
||||
for _, suffix := range envSuffixes {
|
||||
name := "SCRATCHBOX_" + suffix
|
||||
if !strings.Contains(string(envExample), name) {
|
||||
t.Errorf(".env.example is missing %s (add it with a default/example value and a comment matching the style of the others)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Check README.md env documentation (the supported list and the example block)
|
||||
readme, err := os.ReadFile("../../README.md")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read README.md: %v", err)
|
||||
}
|
||||
for _, suffix := range envSuffixes {
|
||||
name := "SCRATCHBOX_" + suffix
|
||||
if !strings.Contains(string(readme), name) {
|
||||
t.Errorf("README.md env docs (supported list or example .env block) are missing %s; update both the bullet list and the ```dotenv block", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+119
-15
@@ -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,19 +140,44 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
// multipartMaxMemory is how much of a multipart upload may live in RAM before
|
||||
// the remainder spills to temporary files. It must NOT be tied to
|
||||
// limits.max_upload_size: when that limit is multi-GiB, using it as maxMemory
|
||||
// forces entire large files into process memory before storage compression.
|
||||
const multipartMaxMemory = 32 << 20 // 32 MiB
|
||||
|
||||
func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxUploadSizeBytes)
|
||||
|
||||
@@ -138,7 +186,7 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
||||
contentType := strings.TrimSpace(r.Header.Get("Content-Type"))
|
||||
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(s.cfg.Limits.MaxUploadSizeBytes); err != nil {
|
||||
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
|
||||
s.writeCreateError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -180,7 +228,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)
|
||||
@@ -273,6 +321,29 @@ func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
}
|
||||
w.Header().Set("Content-Type", meta.ContentType)
|
||||
|
||||
// Scratches larger than the raw cache cannot be retained in memory. Stream
|
||||
// them from storage instead of io.ReadAll so multi-GiB downloads do not OOM.
|
||||
// Range requests are only supported via the cache path (ServeContent needs a Seeker).
|
||||
if meta.Size > s.cfg.Limits.RawCacheMaxBytes {
|
||||
file, _, openErr := s.store.Open(meta.ID)
|
||||
if openErr != nil {
|
||||
if errors.Is(openErr, storage.ErrNotFound) {
|
||||
s.rawCache.Delete(id)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
return
|
||||
}
|
||||
http.Error(w, "failed to read scratch", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
w.Header().Set("Accept-Ranges", "none")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", meta.Size))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(w, file)
|
||||
return
|
||||
}
|
||||
|
||||
content, err, _ := s.rawCache.GetOrLoad(meta.ID, func() ([]byte, error) {
|
||||
file, _, openErr := s.store.Open(meta.ID)
|
||||
if openErr != nil {
|
||||
@@ -332,3 +403,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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
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,421 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Scratches larger than limits.raw_cache_max_size must stream from storage
|
||||
// (no full in-memory buffer) so multi-GiB downloads stay viable.
|
||||
func TestRawStreamsWhenLargerThanCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
payload := bytes.Repeat([]byte("Z"), 8*1024) // 8 KiB body
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: int64(len(payload) * 2),
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
rawCacheBytes: 1024, // smaller than payload => stream path
|
||||
})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewReader(payload))
|
||||
createReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
createReq.RemoteAddr = "127.0.0.1:7100"
|
||||
createRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, want 201 body=%q", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
|
||||
var created map[string]any
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &created); err != nil {
|
||||
t.Fatalf("decode create payload: %v", err)
|
||||
}
|
||||
id, _ := created["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("create payload missing id")
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||
rawReq.RemoteAddr = "127.0.0.1:7101"
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusOK {
|
||||
t.Fatalf("raw status = %d, want 200", rawRec.Code)
|
||||
}
|
||||
if got := rawRec.Header().Get("Accept-Ranges"); got != "none" {
|
||||
t.Fatalf("Accept-Ranges = %q, want none (stream path)", got)
|
||||
}
|
||||
if got := rawRec.Body.Bytes(); !bytes.Equal(got, payload) {
|
||||
t.Fatalf("raw body len=%d, want %d (content mismatch or truncated)", len(got), len(payload))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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
|
||||
rawCacheBytes int64 // 0 => default 64MiB
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
rawCacheBytes := opts.rawCacheBytes
|
||||
if rawCacheBytes <= 0 {
|
||||
rawCacheBytes = 64 * 1024 * 1024
|
||||
}
|
||||
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{
|
||||
ListenAddr: ":0",
|
||||
},
|
||||
Limits: config.LimitsConfig{
|
||||
MaxUploadSizeBytes: opts.maxUploadBytes,
|
||||
DefaultTTLDuration: opts.defaultTTL,
|
||||
RawCacheMaxBytes: rawCacheBytes,
|
||||
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
@@ -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, `&`) || !strings.Contains(got, `<`) || !strings.Contains(got, `>`) || !strings.Contains(got, `"`) {
|
||||
t.Fatalf("injectMeta did not escape specials in output: %q", got)
|
||||
}
|
||||
// Title etc updated (escaped form present).
|
||||
if !strings.Contains(got, `report & "notes" <2026>`) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Scratchbox</title>
|
||||
<meta name="description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
|
||||
<meta property="og:title" content="Scratchbox" />
|
||||
<meta property="og:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Scratchbox" />
|
||||
<meta name="twitter:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<rect x="3" y="3" width="26" height="26" rx="5" fill="#213f75" stroke="#2f67c8" stroke-width="2"/>
|
||||
<text x="16" y="22" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#f5f7fc" text-anchor="middle" dominant-baseline="middle">S</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 367 B |
+37
-2
@@ -30,6 +30,14 @@
|
||||
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
|
||||
const defaultTTLDisplay = $derived(formatTTLDisplay(defaultTTL));
|
||||
|
||||
const viewPageTitle = $derived(
|
||||
viewMeta?.filename
|
||||
? `${viewMeta.filename} • Scratchbox`
|
||||
: scratchID
|
||||
? `${scratchID} • Scratchbox`
|
||||
: "Scratchbox"
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
nowTicker = window.setInterval(() => {
|
||||
nowMs = Date.now();
|
||||
@@ -127,14 +135,16 @@
|
||||
return;
|
||||
}
|
||||
const msg = await metaResponse.text();
|
||||
throw new Error(msg || `Failed to load scratch metadata (${metaResponse.status}).`);
|
||||
viewError = msg || `Failed to load scratch metadata (${metaResponse.status}).`;
|
||||
return;
|
||||
}
|
||||
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}).`);
|
||||
viewError = msg || `Failed to load scratch content (${rawResponse.status}).`;
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = rawResponse.headers.get("content-type") ?? "";
|
||||
@@ -412,6 +422,31 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if routeMode === "view"}
|
||||
<title>{viewPageTitle}</title>
|
||||
<meta property="og:title" content={viewPageTitle} />
|
||||
<meta property="og:description" content="Temporary scratch file shared on Scratchbox." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content={viewPageTitle} />
|
||||
{:else if routeMode === "expired"}
|
||||
<title>Scratch expired • Scratchbox</title>
|
||||
<meta property="og:title" content="Scratch expired • Scratchbox" />
|
||||
<meta property="og:description" content="This scratch is no longer available." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Scratch expired • Scratchbox" />
|
||||
{:else if routeMode === "notfound"}
|
||||
<title>Not found • Scratchbox</title>
|
||||
<meta property="og:title" content="Not found • Scratchbox" />
|
||||
<meta property="og:description" content="The requested path does not exist." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Not found • Scratchbox" />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<main class="container">
|
||||
<header class="site-header">
|
||||
<div class="brand-row">
|
||||
|
||||
Reference in New Issue
Block a user