Merge pull request 'Promote develop: per-scratch TTL' (#6) from develop into master
Release Artifacts / Validate release tag (push) Successful in 2s
Release Artifacts / Build and release executables (push) Successful in 31s
Release Artifacts / Build and release Docker image (push) Successful in 29s
Format / gofmt (push) Successful in 13s
CI / Build (push) Successful in 21s
CI / Go Tests (push) Successful in 23s
Release Artifacts / Validate release tag (push) Successful in 2s
Release Artifacts / Build and release executables (push) Successful in 31s
Release Artifacts / Build and release Docker image (push) Successful in 29s
Format / gofmt (push) Successful in 13s
CI / Build (push) Successful in 21s
CI / Go Tests (push) Successful in 23s
Promote per-scratch TTL to default branch.
This commit was merged in pull request #6.
This commit is contained in:
@@ -218,9 +218,10 @@ All configuration is YAML.
|
|||||||
- If no unit is provided, value is interpreted as bytes
|
- If no unit is provided, value is interpreted as bytes
|
||||||
- Examples: `5MB`, `100MiB`, `1GiB`, `1048576`
|
- Examples: `5MB`, `100MiB`, `1GiB`, `1048576`
|
||||||
- Default: `100MiB`
|
- Default: `100MiB`
|
||||||
- `default_ttl`: expiration applied to new scratches.
|
- `default_ttl`: expiration applied to new scratches when `POST /api/scratch` omits `ttl`.
|
||||||
- Must be `> 0` and `<= 24h`
|
- Must be `> 0` and `<= 24h`
|
||||||
- Default: `15m`
|
- Default: `15m`
|
||||||
|
- Optional per-scratch `ttl` is a Go duration from `1m` through `24h`. Multipart uploads use form field `ttl`; raw-body POSTs use query `ttl`. Invalid or out-of-range values return 400. Infinite TTL is not allowed.
|
||||||
- `raw_cache_max_size`: max total decompressed bytes cached in memory for `/api/raw` range requests.
|
- `raw_cache_max_size`: max total decompressed bytes cached in memory for `/api/raw` range requests.
|
||||||
- Supported units: `B`, `KB`, `MB`, `GB`, `KiB`, `MiB`, `GiB` (case-insensitive)
|
- Supported units: `B`, `KB`, `MB`, `GB`, `KiB`, `MiB`, `GiB` (case-insensitive)
|
||||||
- Must be `> 0`
|
- Must be `> 0`
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ const (
|
|||||||
defaultAPIWriteRequestsPerMinute = 30
|
defaultAPIWriteRequestsPerMinute = 30
|
||||||
defaultAPIWriteRateLimitBurst = 10
|
defaultAPIWriteRateLimitBurst = 10
|
||||||
defaultHSTSEnabled = true
|
defaultHSTSEnabled = true
|
||||||
maxDefaultTTLLimit = 24 * time.Hour
|
// MinScratchTTL / MaxScratchTTL bound optional per-scratch TTL on POST /api/scratch
|
||||||
minCleanupInterval = 10 * time.Second
|
// and also cap limits.default_ttl.
|
||||||
envPrefix = "SCRATCHBOX_"
|
MinScratchTTL = time.Minute
|
||||||
|
MaxScratchTTL = 24 * time.Hour
|
||||||
|
minCleanupInterval = 10 * time.Second
|
||||||
|
envPrefix = "SCRATCHBOX_"
|
||||||
)
|
)
|
||||||
|
|
||||||
// envSuffixes is the single source of truth for every SCRATCHBOX_* environment
|
// envSuffixes is the single source of truth for every SCRATCHBOX_* environment
|
||||||
@@ -138,7 +141,7 @@ type AccessLogConfig struct {
|
|||||||
|
|
||||||
type LimitsConfig struct {
|
type LimitsConfig struct {
|
||||||
MaxUploadSize string `yaml:"max_upload_size" comment:"Maximum upload request body size. Supports B, KB, MB, GB and KiB, MiB, GiB (case-insensitive). Defaults to bytes when unit is omitted (example: 1048576)."`
|
MaxUploadSize string `yaml:"max_upload_size" comment:"Maximum upload request body size. Supports B, KB, MB, GB and KiB, MiB, GiB (case-insensitive). Defaults to bytes when unit is omitted (example: 1048576)."`
|
||||||
DefaultTTL string `yaml:"default_ttl" comment:"Default expiration for new scratches. Must be > 0 and <= 24h."`
|
DefaultTTL string `yaml:"default_ttl" comment:"Default expiration for new scratches when POST /api/scratch omits ttl. Must be > 0 and <= 24h."`
|
||||||
RawCacheMaxSize string `yaml:"raw_cache_max_size" comment:"Maximum total decompressed bytes cached in memory for /api/raw range requests. Supports B, KB, MB, GB and KiB, MiB, GiB. Set this above limits.max_upload_size based on your expected concurrent large downloads."`
|
RawCacheMaxSize string `yaml:"raw_cache_max_size" comment:"Maximum total decompressed bytes cached in memory for /api/raw range requests. Supports B, KB, MB, GB and KiB, MiB, GiB. Set this above limits.max_upload_size based on your expected concurrent large downloads."`
|
||||||
RawCacheMaxEntries int `yaml:"raw_cache_max_entries" comment:"Maximum number of decompressed /api/raw entries retained in memory cache. Use this as a secondary cap alongside raw_cache_max_size."`
|
RawCacheMaxEntries int `yaml:"raw_cache_max_entries" comment:"Maximum number of decompressed /api/raw entries retained in memory cache. Use this as a secondary cap alongside raw_cache_max_size."`
|
||||||
MaxUploadSizeBytes int64 `yaml:"-"`
|
MaxUploadSizeBytes int64 `yaml:"-"`
|
||||||
@@ -326,8 +329,8 @@ func (c *Config) Validate() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("limits.default_ttl invalid: %w", err)
|
return fmt.Errorf("limits.default_ttl invalid: %w", err)
|
||||||
}
|
}
|
||||||
if ttl <= 0 || ttl > maxDefaultTTLLimit {
|
if ttl <= 0 || ttl > MaxScratchTTL {
|
||||||
return fmt.Errorf("limits.default_ttl must be in (0, %s]", maxDefaultTTLLimit)
|
return fmt.Errorf("limits.default_ttl must be in (0, %s]", MaxScratchTTL)
|
||||||
}
|
}
|
||||||
c.Limits.DefaultTTLDuration = ttl
|
c.Limits.DefaultTTLDuration = ttl
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
|
|||||||
if cfg.Limits.DefaultTTLDuration != 15*time.Minute {
|
if cfg.Limits.DefaultTTLDuration != 15*time.Minute {
|
||||||
t.Fatalf("DefaultTTLDuration = %s, want 15m", cfg.Limits.DefaultTTLDuration)
|
t.Fatalf("DefaultTTLDuration = %s, want 15m", cfg.Limits.DefaultTTLDuration)
|
||||||
}
|
}
|
||||||
|
if MaxScratchTTL != 24*time.Hour {
|
||||||
|
t.Fatalf("MaxScratchTTL = %s, want 24h", MaxScratchTTL)
|
||||||
|
}
|
||||||
|
if MinScratchTTL != time.Minute {
|
||||||
|
t.Fatalf("MinScratchTTL = %s, want 1m", MinScratchTTL)
|
||||||
|
}
|
||||||
if cfg.Limits.RawCacheMaxBytes != 200*1024*1024 {
|
if cfg.Limits.RawCacheMaxBytes != 200*1024*1024 {
|
||||||
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(200*1024*1024))
|
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(200*1024*1024))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
"max_upload_size_bytes": s.cfg.Limits.MaxUploadSizeBytes,
|
"max_upload_size_bytes": s.cfg.Limits.MaxUploadSizeBytes,
|
||||||
"upload_allowed": s.isUploadAllowedForRequest(r),
|
"upload_allowed": s.isUploadAllowedForRequest(r),
|
||||||
"default_ttl": s.cfg.Limits.DefaultTTL,
|
"default_ttl": s.cfg.Limits.DefaultTTL,
|
||||||
|
"max_ttl": config.MaxScratchTTL.String(),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, payload)
|
writeJSON(w, http.StatusOK, payload)
|
||||||
}
|
}
|
||||||
@@ -184,8 +185,9 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
|||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
originalName := ""
|
originalName := ""
|
||||||
contentType := strings.TrimSpace(r.Header.Get("Content-Type"))
|
contentType := strings.TrimSpace(r.Header.Get("Content-Type"))
|
||||||
|
multipart := strings.HasPrefix(contentType, "multipart/form-data")
|
||||||
|
|
||||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
if multipart {
|
||||||
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
|
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
|
||||||
s.writeCreateError(w, err)
|
s.writeCreateError(w, err)
|
||||||
return
|
return
|
||||||
@@ -228,7 +230,13 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration)
|
ttl, err := s.resolveTTL(r, multipart)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "request body too large") {
|
if strings.Contains(err.Error(), "request body too large") {
|
||||||
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
||||||
@@ -364,6 +372,33 @@ func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeContent(w, r, meta.ID, meta.CreatedAt, bytes.NewReader(content))
|
http.ServeContent(w, r, meta.ID, meta.CreatedAt, bytes.NewReader(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveTTL returns the per-scratch TTL for POST /api/scratch.
|
||||||
|
// Multipart uploads read form field "ttl"; raw-body uploads read query "ttl".
|
||||||
|
// Omitted or empty values use limits.default_ttl. Invalid or out-of-range
|
||||||
|
// values (below 1m, above MaxScratchTTL, or non-duration) return an error.
|
||||||
|
// Infinite TTL is not allowed.
|
||||||
|
func (s *Server) resolveTTL(r *http.Request, multipart bool) (time.Duration, error) {
|
||||||
|
raw := ""
|
||||||
|
if multipart {
|
||||||
|
raw = strings.TrimSpace(r.FormValue("ttl"))
|
||||||
|
} else {
|
||||||
|
raw = strings.TrimSpace(r.URL.Query().Get("ttl"))
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return s.cfg.Limits.DefaultTTLDuration, nil
|
||||||
|
}
|
||||||
|
parsed, err := time.ParseDuration(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errInvalidScratchTTL
|
||||||
|
}
|
||||||
|
if parsed < config.MinScratchTTL || parsed > config.MaxScratchTTL {
|
||||||
|
return 0, errInvalidScratchTTL
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errInvalidScratchTTL = fmt.Errorf("ttl must be a Go duration between %s and %s", config.MinScratchTTL, config.MaxScratchTTL)
|
||||||
|
|
||||||
func (s *Server) writeCreateError(w http.ResponseWriter, err error) {
|
func (s *Server) writeCreateError(w http.ResponseWriter, err error) {
|
||||||
var maxErr *http.MaxBytesError
|
var maxErr *http.MaxBytesError
|
||||||
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
|
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateScratchTTLQueryOmitUsesDefault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := newTestHandler(t, testServerOptions{
|
||||||
|
maxUploadBytes: 1024,
|
||||||
|
defaultTTL: 15 * time.Minute,
|
||||||
|
rateLimitEnable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
before := time.Now().UTC()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("omit-ttl"))
|
||||||
|
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
req.RemoteAddr = "127.0.0.1:8101"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339Nano, payload["expires_at"].(string))
|
||||||
|
if err != nil {
|
||||||
|
// JSON may marshal as RFC3339
|
||||||
|
expiresAt, err = time.Parse(time.RFC3339, payload["expires_at"].(string))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse expires_at %v (%T): %v", payload["expires_at"], payload["expires_at"], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delta := expiresAt.Sub(before)
|
||||||
|
if delta < 14*time.Minute || delta > 16*time.Minute {
|
||||||
|
t.Fatalf("expires delta = %s, want ~15m", delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateScratchTTLQueryMinMaxAndInvalid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := newTestHandler(t, testServerOptions{
|
||||||
|
maxUploadBytes: 1024,
|
||||||
|
defaultTTL: 15 * time.Minute,
|
||||||
|
rateLimitEnable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ttl string
|
||||||
|
wantStatus int
|
||||||
|
wantDelta time.Duration
|
||||||
|
deltaSlack time.Duration
|
||||||
|
}{
|
||||||
|
{name: "min", ttl: "1m", wantStatus: http.StatusCreated, wantDelta: time.Minute, deltaSlack: 15 * time.Second},
|
||||||
|
{name: "max", ttl: "24h", wantStatus: http.StatusCreated, wantDelta: 24 * time.Hour, deltaSlack: time.Minute},
|
||||||
|
{name: "custom", ttl: "2h", wantStatus: http.StatusCreated, wantDelta: 2 * time.Hour, deltaSlack: 30 * time.Second},
|
||||||
|
{name: "below-min", ttl: "30s", wantStatus: http.StatusBadRequest},
|
||||||
|
{name: "above-max", ttl: "25h", wantStatus: http.StatusBadRequest},
|
||||||
|
{name: "invalid", ttl: "not-a-duration", wantStatus: http.StatusBadRequest},
|
||||||
|
{name: "zero", ttl: "0s", wantStatus: http.StatusBadRequest},
|
||||||
|
{name: "negative", ttl: "-5m", wantStatus: http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
before := time.Now().UTC()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch?ttl="+tc.ttl, bytes.NewBufferString("body-"+tc.name))
|
||||||
|
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
req.RemoteAddr = "127.0.0.1:8200"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("status = %d, want %d body=%q", rec.Code, tc.wantStatus, rec.Body.String())
|
||||||
|
}
|
||||||
|
if tc.wantStatus != http.StatusCreated {
|
||||||
|
if !strings.Contains(rec.Body.String(), "ttl") {
|
||||||
|
t.Fatalf("expected ttl error message, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
expiresRaw, _ := payload["expires_at"].(string)
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339Nano, expiresRaw)
|
||||||
|
if err != nil {
|
||||||
|
expiresAt, err = time.Parse(time.RFC3339, expiresRaw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse expires_at %q: %v", expiresRaw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delta := expiresAt.Sub(before)
|
||||||
|
if delta < tc.wantDelta-tc.deltaSlack || delta > tc.wantDelta+tc.deltaSlack {
|
||||||
|
t.Fatalf("expires delta = %s, want ~%s", delta, tc.wantDelta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateScratchTTLMultipartFormField(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := newTestHandler(t, testServerOptions{
|
||||||
|
maxUploadBytes: 1024,
|
||||||
|
defaultTTL: 15 * time.Minute,
|
||||||
|
rateLimitEnable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
file, err := writer.CreateFormFile("file", "ttl.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write([]byte("multipart-ttl")); err != nil {
|
||||||
|
t.Fatalf("write file: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.WriteField("ttl", "5m"); err != nil {
|
||||||
|
t.Fatalf("WriteField ttl: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now().UTC()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.RemoteAddr = "127.0.0.1:8301"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusCreated, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode payload: %v", err)
|
||||||
|
}
|
||||||
|
expiresRaw, _ := payload["expires_at"].(string)
|
||||||
|
expiresAt, err := time.Parse(time.RFC3339Nano, expiresRaw)
|
||||||
|
if err != nil {
|
||||||
|
expiresAt, err = time.Parse(time.RFC3339, expiresRaw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse expires_at %q: %v", expiresRaw, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delta := expiresAt.Sub(before)
|
||||||
|
if delta < 4*time.Minute || delta > 6*time.Minute {
|
||||||
|
t.Fatalf("expires delta = %s, want ~5m", delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateScratchTTLMultipartInvalid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := newTestHandler(t, testServerOptions{
|
||||||
|
maxUploadBytes: 1024,
|
||||||
|
defaultTTL: 15 * time.Minute,
|
||||||
|
rateLimitEnable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
file, _ := writer.CreateFormFile("file", "bad-ttl.txt")
|
||||||
|
_, _ = file.Write([]byte("x"))
|
||||||
|
_ = writer.WriteField("ttl", "forever")
|
||||||
|
_ = writer.Close()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.RemoteAddr = "127.0.0.1:8302"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d body=%q", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUIConfigExposesMaxTTL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := newTestHandler(t, testServerOptions{
|
||||||
|
maxUploadBytes: 2048,
|
||||||
|
defaultTTL: 15 * time.Minute,
|
||||||
|
rateLimitEnable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// DefaultTTL string is empty in test helper; set via direct server call already covered.
|
||||||
|
// Hit routed /api/config and ensure max_ttl is present.
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||||
|
req.RemoteAddr = "127.0.0.1:8401"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, `"max_ttl"`) {
|
||||||
|
t.Fatalf("expected max_ttl in body: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"scratchbox/internal/config"
|
"scratchbox/internal/config"
|
||||||
"scratchbox/internal/storage"
|
"scratchbox/internal/storage"
|
||||||
@@ -88,11 +90,76 @@ func TestGetUIConfig(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`) {
|
if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`, `"max_ttl":"`+config.MaxScratchTTL.String()+`"`, "default_ttl") {
|
||||||
t.Fatalf("unexpected body: %q", got)
|
t.Fatalf("unexpected body: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveTTL(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
cfg: config.Config{
|
||||||
|
Limits: config.LimitsConfig{
|
||||||
|
DefaultTTLDuration: 15 * time.Minute,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
formTTL string
|
||||||
|
multipart bool
|
||||||
|
want time.Duration
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "omit raw uses default", url: "/api/scratch", want: 15 * time.Minute},
|
||||||
|
{name: "empty query uses default", url: "/api/scratch?ttl=", want: 15 * time.Minute},
|
||||||
|
{name: "empty form uses default", url: "/api/scratch", multipart: true, formTTL: " ", want: 15 * time.Minute},
|
||||||
|
{name: "query min", url: "/api/scratch?ttl=1m", want: time.Minute},
|
||||||
|
{name: "query max", url: "/api/scratch?ttl=24h", want: config.MaxScratchTTL},
|
||||||
|
{name: "query default value", url: "/api/scratch?ttl=15m", want: 15 * time.Minute},
|
||||||
|
{name: "form min", url: "/api/scratch", multipart: true, formTTL: "1m", want: time.Minute},
|
||||||
|
{name: "form max", url: "/api/scratch", multipart: true, formTTL: "24h", want: config.MaxScratchTTL},
|
||||||
|
{name: "form default value", url: "/api/scratch", multipart: true, formTTL: "15m", want: 15 * time.Minute},
|
||||||
|
{name: "invalid duration", url: "/api/scratch?ttl=nope", wantErr: true},
|
||||||
|
{name: "below min", url: "/api/scratch?ttl=59s", wantErr: true},
|
||||||
|
{name: "above max", url: "/api/scratch?ttl=24h1s", wantErr: true},
|
||||||
|
{name: "zero", url: "/api/scratch?ttl=0", wantErr: true},
|
||||||
|
{name: "negative", url: "/api/scratch?ttl=-1m", wantErr: true},
|
||||||
|
{name: "form invalid", url: "/api/scratch", multipart: true, formTTL: "forever", wantErr: true},
|
||||||
|
{name: "form below min", url: "/api/scratch", multipart: true, formTTL: "30s", wantErr: true},
|
||||||
|
{name: "form above max", url: "/api/scratch", multipart: true, formTTL: "25h", wantErr: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, tc.url, nil)
|
||||||
|
if tc.multipart {
|
||||||
|
req.Form = url.Values{}
|
||||||
|
if tc.formTTL != "" {
|
||||||
|
req.Form.Set("ttl", tc.formTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := s.resolveTTL(req, tc.multipart)
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("resolveTTL() error = nil, want error (got %s)", got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveTTL() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("resolveTTL() = %s, want %s", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetUIConfigUploadDenied(t *testing.T) {
|
func TestGetUIConfigUploadDenied(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+106
-2
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
let maxUploadSizeBytes = $state(0);
|
let maxUploadSizeBytes = $state(0);
|
||||||
let defaultTTL = $state("15m");
|
let defaultTTL = $state("15m");
|
||||||
|
let maxTTL = $state("24h");
|
||||||
|
let selectedTTL = $state("15m");
|
||||||
let uploadAllowed = $state(true);
|
let uploadAllowed = $state(true);
|
||||||
let configError = $state("");
|
let configError = $state("");
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@
|
|||||||
|
|
||||||
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
|
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
|
||||||
const defaultTTLDisplay = $derived(formatTTLDisplay(defaultTTL));
|
const defaultTTLDisplay = $derived(formatTTLDisplay(defaultTTL));
|
||||||
|
const maxTTLDisplay = $derived(formatTTLDisplay(maxTTL));
|
||||||
|
const ttlOptions = $derived(buildTTLOptions(defaultTTL, maxTTL));
|
||||||
|
|
||||||
const viewPageTitle = $derived(
|
const viewPageTitle = $derived(
|
||||||
viewMeta?.filename
|
viewMeta?.filename
|
||||||
@@ -101,11 +105,15 @@
|
|||||||
maxUploadSizeBytes = Number.isFinite(max) ? max : 0;
|
maxUploadSizeBytes = Number.isFinite(max) ? max : 0;
|
||||||
uploadAllowed = Boolean(data.upload_allowed);
|
uploadAllowed = Boolean(data.upload_allowed);
|
||||||
defaultTTL = String(data.default_ttl ?? "").trim() || "15m";
|
defaultTTL = String(data.default_ttl ?? "").trim() || "15m";
|
||||||
|
maxTTL = String(data.max_ttl ?? "").trim() || "24h";
|
||||||
|
selectedTTL = defaultTTL;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
configError = err instanceof Error ? err.message : String(err);
|
configError = err instanceof Error ? err.message : String(err);
|
||||||
maxUploadSizeBytes = 0;
|
maxUploadSizeBytes = 0;
|
||||||
uploadAllowed = true;
|
uploadAllowed = true;
|
||||||
defaultTTL = "15m";
|
defaultTTL = "15m";
|
||||||
|
maxTTL = "24h";
|
||||||
|
selectedTTL = defaultTTL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +313,85 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function parseTTLToSeconds(rawTTL) {
|
||||||
|
const source = String(rawTTL ?? "").trim().toLowerCase();
|
||||||
|
if (!source) {
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
const matches = Array.from(source.matchAll(/(\d+)\s*([hms])/g));
|
||||||
|
if (matches.length === 0) {
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
let total = 0;
|
||||||
|
for (const match of matches) {
|
||||||
|
const value = Number.parseInt(match[1], 10);
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const unit = match[2];
|
||||||
|
if (unit === "h") {
|
||||||
|
total += value * 3600;
|
||||||
|
} else if (unit === "m") {
|
||||||
|
total += value * 60;
|
||||||
|
} else if (unit === "s") {
|
||||||
|
total += value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTTLOptions(defaultRaw, maxRaw) {
|
||||||
|
const presets = ["1m", "5m", "15m", "30m", "1h", "6h", "12h", "24h"];
|
||||||
|
const maxSeconds = parseTTLToSeconds(maxRaw);
|
||||||
|
const defaultSeconds = parseTTLToSeconds(defaultRaw);
|
||||||
|
const seen = new Set();
|
||||||
|
const options = [];
|
||||||
|
|
||||||
|
function addOption(value) {
|
||||||
|
const seconds = parseTTLToSeconds(value);
|
||||||
|
if (!Number.isFinite(seconds) || seconds < 60) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(maxSeconds) && seconds > maxSeconds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seen.has(seconds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(seconds);
|
||||||
|
options.push({
|
||||||
|
value,
|
||||||
|
label: formatTTLDisplay(value),
|
||||||
|
seconds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const preset of presets) {
|
||||||
|
addOption(preset);
|
||||||
|
}
|
||||||
|
addOption(defaultRaw);
|
||||||
|
addOption(maxRaw);
|
||||||
|
|
||||||
|
options.sort((a, b) => a.seconds - b.seconds);
|
||||||
|
|
||||||
|
// Ensure selected/default is present even if parsing failed above.
|
||||||
|
if (defaultRaw && !options.some((opt) => opt.value === defaultRaw)) {
|
||||||
|
options.unshift({
|
||||||
|
value: defaultRaw,
|
||||||
|
label: formatTTLDisplay(defaultRaw),
|
||||||
|
seconds: Number.isFinite(defaultSeconds) ? defaultSeconds : 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return options.map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label:
|
||||||
|
opt.value === defaultRaw
|
||||||
|
? `${opt.label} (default)`
|
||||||
|
: opt.label
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function expiresInText(expiresAt, now) {
|
function expiresInText(expiresAt, now) {
|
||||||
const expiry = Date.parse(String(expiresAt ?? ""));
|
const expiry = Date.parse(String(expiresAt ?? ""));
|
||||||
if (!Number.isFinite(expiry)) {
|
if (!Number.isFinite(expiry)) {
|
||||||
@@ -393,6 +480,8 @@
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", selectedFile);
|
formData.append("file", selectedFile);
|
||||||
|
const ttlValue = String(selectedTTL ?? "").trim() || defaultTTL;
|
||||||
|
formData.append("ttl", ttlValue);
|
||||||
const response = await fetch("/api/scratch", {
|
const response = await fetch("/api/scratch", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData
|
body: formData
|
||||||
@@ -509,7 +598,7 @@
|
|||||||
{:else if routeMode === "upload"}
|
{:else if routeMode === "upload"}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<p class="helper">Size limit: {maxUploadSizeDisplay}</p>
|
<p class="helper">Size limit: {maxUploadSizeDisplay}</p>
|
||||||
<p class="helper">Expiration: {defaultTTLDisplay}</p>
|
<p class="helper">Default expiration: {defaultTTLDisplay} (max {maxTTLDisplay})</p>
|
||||||
{#if configError}
|
{#if configError}
|
||||||
<p class="helper warning">Could not load UI config: {configError}</p>
|
<p class="helper warning">Could not load UI config: {configError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -530,6 +619,20 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="ttl-panel" class="input-panel">
|
||||||
|
<label for="ttl">Expiration</label>
|
||||||
|
<select
|
||||||
|
id="ttl"
|
||||||
|
name="ttl"
|
||||||
|
bind:value={selectedTTL}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{#each ttlOptions as option}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button class="link-button nav-button action-button submit-button" type="submit" disabled={loading}>
|
<button class="link-button nav-button action-button submit-button" type="submit" disabled={loading}>
|
||||||
{#if loading}Creating...{:else}Create scratch{/if}
|
{#if loading}Creating...{:else}Create scratch{/if}
|
||||||
</button>
|
</button>
|
||||||
@@ -657,7 +760,8 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="file"] {
|
input[type="file"],
|
||||||
|
select {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #121826;
|
background: #121826;
|
||||||
|
|||||||
Reference in New Issue
Block a user