Many changes
This commit is contained in:
@@ -7,3 +7,6 @@
|
|||||||
|
|
||||||
# Coverage outputs
|
# Coverage outputs
|
||||||
/*.out
|
/*.out
|
||||||
|
|
||||||
|
# Frontend dependencies
|
||||||
|
/web/ui/node_modules/
|
||||||
@@ -24,11 +24,14 @@ Key product constraints:
|
|||||||
- `POST /api/scratch`
|
- `POST /api/scratch`
|
||||||
- `GET /api/scratch/{id}`
|
- `GET /api/scratch/{id}`
|
||||||
- `GET /s/{id}`
|
- `GET /s/{id}`
|
||||||
- `GET /raw/{id}`
|
- `GET /api/raw/{id}`
|
||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
Use `make help` as the source of truth for available build/test targets and descriptions.
|
Use `make help` as the source of truth for available build/test targets and descriptions.
|
||||||
|
Always build and test via make unless it doesn't include targets for that.
|
||||||
|
Also in the event there is a test without race checking and one with do both.
|
||||||
|
Otherwise just whats available.
|
||||||
To verify your changes you must run tests and do a build.
|
To verify your changes you must run tests and do a build.
|
||||||
|
|
||||||
## Coding conventions
|
## Coding conventions
|
||||||
|
|||||||
@@ -4,15 +4,16 @@ BIN_DIR := ./bin
|
|||||||
BIN := $(BIN_DIR)/$(APP_NAME)
|
BIN := $(BIN_DIR)/$(APP_NAME)
|
||||||
CONFIG ?= config.yaml
|
CONFIG ?= config.yaml
|
||||||
|
|
||||||
.PHONY: help run dev generate-config build test fmt clean
|
.PHONY: help run dev generate-config build test test-race fmt clean
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
@echo " make run - Alias for make dev"
|
@echo " make run - Alias for make dev"
|
||||||
@echo " make dev - Build and run server (CONFIG=$(CONFIG))"
|
@echo " make dev - Build and run server (CONFIG=$(CONFIG))"
|
||||||
@echo " make generate-config - Write default config to $(CONFIG)"
|
@echo " make generate-config - Write default config to $(CONFIG)"
|
||||||
@echo " make build - Build binary to $(BIN)"
|
@echo " make build - Build Svelte UI and binary to $(BIN)"
|
||||||
@echo " make test - Run all Go tests"
|
@echo " make test - Run all Go tests with timeout and shuffle"
|
||||||
|
@echo " make test-race - Run all Go tests with race detector and timeout"
|
||||||
@echo " make fmt - Format Go code"
|
@echo " make fmt - Format Go code"
|
||||||
@echo " make clean - Remove built artifacts"
|
@echo " make clean - Remove built artifacts"
|
||||||
|
|
||||||
@@ -25,11 +26,15 @@ generate-config:
|
|||||||
go run $(CMD_DIR) generate-config > $(CONFIG)
|
go run $(CMD_DIR) generate-config > $(CONFIG)
|
||||||
|
|
||||||
build: clean
|
build: clean
|
||||||
|
npm --prefix ./web/ui run build
|
||||||
mkdir -p $(BIN_DIR)
|
mkdir -p $(BIN_DIR)
|
||||||
go build -o $(BIN) $(CMD_DIR)
|
go build -o $(BIN) $(CMD_DIR)
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test -timeout 30s -shuffle=on ./...
|
||||||
|
|
||||||
|
test-race:
|
||||||
|
go test -race -timeout 30s -shuffle=on ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ Scratch content is stored on disk gzip-compressed transparently to reduce filesy
|
|||||||
|
|
||||||
Without `--config`, built-in defaults are used.
|
Without `--config`, built-in defaults are used.
|
||||||
|
|
||||||
|
If you modify the web UI source (`web/ui`), rebuild frontend assets with:
|
||||||
|
- `make build`
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration is YAML.
|
All configuration is YAML.
|
||||||
@@ -29,11 +32,21 @@ All configuration is YAML.
|
|||||||
### `limits`
|
### `limits`
|
||||||
|
|
||||||
- `max_upload_size`: max request body size for uploads.
|
- `max_upload_size`: max request body size for uploads.
|
||||||
- Examples: `5MB`, `100MB`, `1GiB`
|
- Supported units: `B`, `KB`, `MB`, `GB`, `KiB`, `MiB`, `GiB` (case-insensitive)
|
||||||
- Default: `100MB`
|
- If no unit is provided, value is interpreted as bytes
|
||||||
|
- Examples: `5MB`, `100MiB`, `1GiB`, `1048576`
|
||||||
|
- Default: `100MiB`
|
||||||
- `default_ttl`: expiration applied to new scratches.
|
- `default_ttl`: expiration applied to new scratches.
|
||||||
- Must be `> 0` and `<= 24h`
|
- Must be `> 0` and `<= 24h`
|
||||||
- Default: `1h`
|
- Default: `1h`
|
||||||
|
- `raw_cache_max_size`: max total decompressed bytes cached in memory for `/api/raw` range requests.
|
||||||
|
- Supported units: `B`, `KB`, `MB`, `GB`, `KiB`, `MiB`, `GiB` (case-insensitive)
|
||||||
|
- Must be `> 0`
|
||||||
|
- Default: `200MiB`
|
||||||
|
- `raw_cache_max_entries`: max number of decompressed `/api/raw` entries kept in memory.
|
||||||
|
- Must be `> 0`
|
||||||
|
- Used together with `raw_cache_max_size` as a secondary cap
|
||||||
|
- Default: `32`
|
||||||
|
|
||||||
### `storage`
|
### `storage`
|
||||||
|
|
||||||
@@ -47,6 +60,8 @@ All configuration is YAML.
|
|||||||
|
|
||||||
- `allowed_ips`: optional list of IPs and CIDRs allowed to create scratches.
|
- `allowed_ips`: optional list of IPs and CIDRs allowed to create scratches.
|
||||||
- Applies to write route only: `POST /api/scratch`
|
- Applies to write route only: `POST /api/scratch`
|
||||||
|
- Default includes `127.0.0.1` (localhost-only write access)
|
||||||
|
- Add trusted IPs/CIDRs for LAN/proxy clients (for example `192.168.1.0/24`)
|
||||||
- Empty list means no allowlist restriction
|
- Empty list means no allowlist restriction
|
||||||
- `trust_proxy_headers`: if `true`, client IP extraction honors `X-Forwarded-For` then `X-Real-IP`.
|
- `trust_proxy_headers`: if `true`, client IP extraction honors `X-Forwarded-For` then `X-Real-IP`.
|
||||||
- Keep `false` unless behind a trusted reverse proxy that sets these headers.
|
- Keep `false` unless behind a trusted reverse proxy that sets these headers.
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -22,32 +25,11 @@ func newGenerateConfigCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeDefaultConfig(cmd *cobra.Command) error {
|
func writeDefaultConfig(cmd *cobra.Command) error {
|
||||||
cfg := config.Config{
|
cfg := config.Default()
|
||||||
Server: config.ServerConfig{
|
|
||||||
ListenAddr: ":8080",
|
|
||||||
},
|
|
||||||
Limits: config.LimitsConfig{
|
|
||||||
MaxUploadSize: "100MB",
|
|
||||||
DefaultTTL: "1h",
|
|
||||||
},
|
|
||||||
Storage: config.StorageConfig{
|
|
||||||
DataDir: "./data",
|
|
||||||
CleanupInterval: "1m",
|
|
||||||
},
|
|
||||||
Security: config.SecurityConfig{
|
|
||||||
AllowedIPs: []string{},
|
|
||||||
TrustProxyHeaders: false,
|
|
||||||
RateLimit: config.RateLimitConfig{
|
|
||||||
Enabled: true,
|
|
||||||
RequestsPerMinute: 30,
|
|
||||||
Burst: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := yaml.Marshal(cfg)
|
b, err := marshalYAMLWithComments(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal default config: %w", err)
|
return fmt.Errorf("marshal default config with comments: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := cmd.OutOrStdout().Write(b); err != nil {
|
if _, err := cmd.OutOrStdout().Write(b); err != nil {
|
||||||
@@ -56,3 +38,111 @@ func writeDefaultConfig(cmd *cobra.Command) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func marshalYAMLWithComments(v any) ([]byte, error) {
|
||||||
|
root, err := nodeFromValue(reflect.ValueOf(v))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := yaml.NewEncoder(&buf)
|
||||||
|
enc.SetIndent(4)
|
||||||
|
if err := enc.Encode(root); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode yaml: %w", err)
|
||||||
|
}
|
||||||
|
if err := enc.Close(); err != nil {
|
||||||
|
return nil, fmt.Errorf("close yaml encoder: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeFromValue(value reflect.Value) (*yaml.Node, error) {
|
||||||
|
if !value.IsValid() {
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for value.Kind() == reflect.Ptr {
|
||||||
|
if value.IsNil() {
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
||||||
|
}
|
||||||
|
value = value.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.Kind() == reflect.Struct {
|
||||||
|
return nodeFromStruct(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scalarOrCollectionNode(value.Interface())
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeFromStruct(value reflect.Value) (*yaml.Node, error) {
|
||||||
|
typ := value.Type()
|
||||||
|
mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||||
|
|
||||||
|
for i := 0; i < typ.NumField(); i++ {
|
||||||
|
field := typ.Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key, skip := yamlFieldName(field)
|
||||||
|
if skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
keyNode := &yaml.Node{
|
||||||
|
Kind: yaml.ScalarNode,
|
||||||
|
Tag: "!!str",
|
||||||
|
Value: key,
|
||||||
|
}
|
||||||
|
if comment := strings.TrimSpace(field.Tag.Get("comment")); comment != "" {
|
||||||
|
keyNode.HeadComment = comment
|
||||||
|
}
|
||||||
|
|
||||||
|
valueNode, err := nodeFromValue(value.Field(i))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("field %s: %w", field.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping.Content = append(mapping.Content, keyNode, valueNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scalarOrCollectionNode(value any) (*yaml.Node, error) {
|
||||||
|
b, err := yaml.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal value node: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc yaml.Node
|
||||||
|
if err := yaml.Unmarshal(b, &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal value node: %w", err)
|
||||||
|
}
|
||||||
|
if len(doc.Content) == 0 {
|
||||||
|
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil
|
||||||
|
}
|
||||||
|
return doc.Content[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func yamlFieldName(field reflect.StructField) (string, bool) {
|
||||||
|
tag := strings.TrimSpace(field.Tag.Get("yaml"))
|
||||||
|
if tag == "-" {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
if tag == "" {
|
||||||
|
return strings.ToLower(field.Name), false
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(tag, ",")
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
if key == "-" {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return strings.ToLower(field.Name), false
|
||||||
|
}
|
||||||
|
return key, false
|
||||||
|
}
|
||||||
|
|||||||
+14
-53
@@ -16,10 +16,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
"scratchbox/internal/config"
|
"scratchbox/internal/config"
|
||||||
httpapi "scratchbox/internal/http"
|
httpapi "scratchbox/internal/http"
|
||||||
"scratchbox/internal/storage"
|
"scratchbox/internal/storage"
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func repoRoot(t *testing.T) string {
|
func repoRoot(t *testing.T) string {
|
||||||
@@ -125,44 +125,6 @@ security:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMainProcessHTTPInitFails(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tmp := t.TempDir()
|
|
||||||
cfgPath := filepath.Join(tmp, "config.yaml")
|
|
||||||
cfg := `
|
|
||||||
server:
|
|
||||||
listen_addr: "127.0.0.1:0"
|
|
||||||
limits:
|
|
||||||
max_upload_size: "1MB"
|
|
||||||
default_ttl: "1h"
|
|
||||||
storage:
|
|
||||||
data_dir: "` + filepath.Join(tmp, "data") + `"
|
|
||||||
cleanup_interval: "10s"
|
|
||||||
security:
|
|
||||||
allowed_ips: []
|
|
||||||
trust_proxy_headers: false
|
|
||||||
rate_limit:
|
|
||||||
enabled: true
|
|
||||||
requests_per_minute: 10
|
|
||||||
burst: 1
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(os.Args[0], "-test.run=TestMainHelperProcess")
|
|
||||||
cmd.Dir = repoRoot(t)
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GO_WANT_MAIN_HELPER=1",
|
|
||||||
"MAIN_HELPER_MODE=http-fail",
|
|
||||||
"MAIN_HELPER_CONFIG="+cfgPath,
|
|
||||||
)
|
|
||||||
if err := cmd.Run(); err == nil {
|
|
||||||
t.Fatalf("expected helper process failure for http init")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMainHelperProcess(t *testing.T) {
|
func TestMainHelperProcess(t *testing.T) {
|
||||||
if os.Getenv("GO_WANT_MAIN_HELPER") != "1" {
|
if os.Getenv("GO_WANT_MAIN_HELPER") != "1" {
|
||||||
return
|
return
|
||||||
@@ -193,14 +155,6 @@ func TestMainHelperProcess(t *testing.T) {
|
|||||||
os.Args = []string{"scratchbox", "server", "--config", cfgPath}
|
os.Args = []string{"scratchbox", "server", "--config", cfgPath}
|
||||||
main()
|
main()
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
case "http-fail":
|
|
||||||
cfgPath := os.Getenv("MAIN_HELPER_CONFIG")
|
|
||||||
if err := os.Chdir(t.TempDir()); err != nil {
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
os.Args = []string{"scratchbox", "server", "--config", cfgPath}
|
|
||||||
main()
|
|
||||||
os.Exit(0)
|
|
||||||
default:
|
default:
|
||||||
os.Exit(2)
|
os.Exit(2)
|
||||||
}
|
}
|
||||||
@@ -222,17 +176,24 @@ func TestGenerateConfigCommandWritesDefaultYAML(t *testing.T) {
|
|||||||
if out.Len() == 0 {
|
if out.Len() == 0 {
|
||||||
t.Fatalf("generate-config output is empty")
|
t.Fatalf("generate-config output is empty")
|
||||||
}
|
}
|
||||||
|
output := out.String()
|
||||||
|
if !strings.Contains(output, "# HTTP listener settings.") {
|
||||||
|
t.Fatalf("generate-config output missing expected section comment")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "# 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).") {
|
||||||
|
t.Fatalf("generate-config output missing expected field comment")
|
||||||
|
}
|
||||||
|
|
||||||
var cfg config.Config
|
var cfg config.Config
|
||||||
if err := yaml.Unmarshal(out.Bytes(), &cfg); err != nil {
|
if err := yaml.Unmarshal([]byte(output), &cfg); err != nil {
|
||||||
t.Fatalf("yaml.Unmarshal() error = %v", err)
|
t.Fatalf("yaml.Unmarshal() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Server.ListenAddr != ":8080" {
|
if cfg.Server.ListenAddr != ":8080" {
|
||||||
t.Fatalf("server.listen_addr = %q, want %q", cfg.Server.ListenAddr, ":8080")
|
t.Fatalf("server.listen_addr = %q, want %q", cfg.Server.ListenAddr, ":8080")
|
||||||
}
|
}
|
||||||
if cfg.Limits.MaxUploadSize != "100MB" {
|
if cfg.Limits.MaxUploadSize != "100MiB" {
|
||||||
t.Fatalf("limits.max_upload_size = %q, want %q", cfg.Limits.MaxUploadSize, "100MB")
|
t.Fatalf("limits.max_upload_size = %q, want %q", cfg.Limits.MaxUploadSize, "100MiB")
|
||||||
}
|
}
|
||||||
if cfg.Storage.DataDir != "./data" {
|
if cfg.Storage.DataDir != "./data" {
|
||||||
t.Fatalf("storage.data_dir = %q, want %q", cfg.Storage.DataDir, "./data")
|
t.Fatalf("storage.data_dir = %q, want %q", cfg.Storage.DataDir, "./data")
|
||||||
@@ -349,7 +310,7 @@ security:
|
|||||||
t.Fatalf("create payload missing id")
|
t.Fatalf("create payload missing id")
|
||||||
}
|
}
|
||||||
|
|
||||||
rawResp, err := client.Get(baseURL + "/raw/" + id)
|
rawResp, err := client.Get(baseURL + "/api/raw/" + id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("client.Get(raw) error = %v", err)
|
t.Fatalf("client.Get(raw) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -377,7 +338,7 @@ security:
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReadAll(view) error = %v", err)
|
t.Fatalf("ReadAll(view) error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(viewBody), "Scratch "+id) {
|
if !strings.Contains(string(viewBody), `id="app"`) {
|
||||||
t.Fatalf("view body missing scratch id")
|
t.Fatalf("view body missing SPA mount point")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-18
@@ -15,8 +15,10 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultListenAddr = ":8080"
|
defaultListenAddr = ":8080"
|
||||||
defaultMaxUploadSize = "100MB"
|
defaultMaxUploadSize = "100MiB"
|
||||||
defaultTTL = "1h"
|
defaultTTL = "1h"
|
||||||
|
defaultRawCacheMaxSize = "200MiB"
|
||||||
|
defaultRawCacheMaxEntries = 32
|
||||||
defaultDataDir = "./data"
|
defaultDataDir = "./data"
|
||||||
defaultCleanupInterval = "1m"
|
defaultCleanupInterval = "1m"
|
||||||
defaultRateLimitEnabled = true
|
defaultRateLimitEnabled = true
|
||||||
@@ -27,40 +29,43 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `yaml:"server"`
|
Server ServerConfig `yaml:"server" comment:"HTTP listener settings."`
|
||||||
Limits LimitsConfig `yaml:"limits"`
|
Limits LimitsConfig `yaml:"limits" comment:"Upload, expiration, and raw cache defaults."`
|
||||||
Storage StorageConfig `yaml:"storage"`
|
Storage StorageConfig `yaml:"storage" comment:"Filesystem storage location and cleanup cadence."`
|
||||||
Security SecurityConfig `yaml:"security"`
|
Security SecurityConfig `yaml:"security" comment:"Write-route IP restrictions and rate limiting."`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
ListenAddr string `yaml:"listen_addr"`
|
ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LimitsConfig struct {
|
type LimitsConfig struct {
|
||||||
MaxUploadSize string `yaml:"max_upload_size"`
|
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"`
|
DefaultTTL string `yaml:"default_ttl" comment:"Default expiration for new scratches. Must be > 0 and <= 24h."`
|
||||||
|
RawCacheMaxSize string `yaml:"raw_cache_max_size" comment:"Maximum total decompressed bytes cached in memory for /api/raw range requests. Supports B, KB, MB, GB and KiB, MiB, GiB. Set this above limits.max_upload_size based on your expected concurrent large downloads."`
|
||||||
|
RawCacheMaxEntries int `yaml:"raw_cache_max_entries" comment:"Maximum number of decompressed /api/raw entries retained in memory cache. Use this as a secondary cap alongside raw_cache_max_size."`
|
||||||
MaxUploadSizeBytes int64 `yaml:"-"`
|
MaxUploadSizeBytes int64 `yaml:"-"`
|
||||||
DefaultTTLDuration time.Duration `yaml:"-"`
|
DefaultTTLDuration time.Duration `yaml:"-"`
|
||||||
|
RawCacheMaxBytes int64 `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
DataDir string `yaml:"data_dir"`
|
DataDir string `yaml:"data_dir" comment:"Directory where scratch files and metadata are stored."`
|
||||||
CleanupInterval string `yaml:"cleanup_interval"`
|
CleanupInterval string `yaml:"cleanup_interval" comment:"How often expired scratches are deleted (minimum 10s)."`
|
||||||
CleanupIntervalParsed time.Duration `yaml:"-"`
|
CleanupIntervalParsed time.Duration `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecurityConfig struct {
|
type SecurityConfig struct {
|
||||||
AllowedIPs []string `yaml:"allowed_ips"`
|
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"`
|
TrustProxyHeaders bool `yaml:"trust_proxy_headers" comment:"Honor X-Forwarded-For / X-Real-IP when true (trusted proxy only)."`
|
||||||
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
RateLimit RateLimitConfig `yaml:"rate_limit" comment:"Per-client token bucket on POST /api/scratch."`
|
||||||
AllowedPrefixes []netip.Prefix `yaml:"-"`
|
AllowedPrefixes []netip.Prefix `yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RateLimitConfig struct {
|
type RateLimitConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled" comment:"Enable per-client write-route rate limiting."`
|
||||||
RequestsPerMinute int `yaml:"requests_per_minute"`
|
RequestsPerMinute int `yaml:"requests_per_minute" comment:"Steady refill rate per client IP."`
|
||||||
Burst int `yaml:"burst"`
|
Burst int `yaml:"burst" comment:"Maximum immediate burst capacity per client IP."`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(path string) (Config, error) {
|
func Load(path string) (Config, error) {
|
||||||
@@ -81,6 +86,10 @@ func Load(path string) (Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Default() Config {
|
||||||
|
return defaults()
|
||||||
|
}
|
||||||
|
|
||||||
func defaults() Config {
|
func defaults() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
@@ -89,12 +98,17 @@ func defaults() Config {
|
|||||||
Limits: LimitsConfig{
|
Limits: LimitsConfig{
|
||||||
MaxUploadSize: defaultMaxUploadSize,
|
MaxUploadSize: defaultMaxUploadSize,
|
||||||
DefaultTTL: defaultTTL,
|
DefaultTTL: defaultTTL,
|
||||||
|
RawCacheMaxSize: defaultRawCacheMaxSize,
|
||||||
|
RawCacheMaxEntries: defaultRawCacheMaxEntries,
|
||||||
},
|
},
|
||||||
Storage: StorageConfig{
|
Storage: StorageConfig{
|
||||||
DataDir: defaultDataDir,
|
DataDir: defaultDataDir,
|
||||||
CleanupInterval: defaultCleanupInterval,
|
CleanupInterval: defaultCleanupInterval,
|
||||||
},
|
},
|
||||||
Security: SecurityConfig{
|
Security: SecurityConfig{
|
||||||
|
AllowedIPs: []string{
|
||||||
|
"127.0.0.1",
|
||||||
|
},
|
||||||
RateLimit: RateLimitConfig{
|
RateLimit: RateLimitConfig{
|
||||||
Enabled: defaultRateLimitEnabled,
|
Enabled: defaultRateLimitEnabled,
|
||||||
RequestsPerMinute: defaultRequestsPerMinute,
|
RequestsPerMinute: defaultRequestsPerMinute,
|
||||||
@@ -124,6 +138,15 @@ func (c *Config) Validate() error {
|
|||||||
}
|
}
|
||||||
c.Limits.DefaultTTLDuration = ttl
|
c.Limits.DefaultTTLDuration = ttl
|
||||||
|
|
||||||
|
rawCacheBytes, err := parseHumanSize(c.Limits.RawCacheMaxSize)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("limits.raw_cache_max_size invalid: %w", err)
|
||||||
|
}
|
||||||
|
c.Limits.RawCacheMaxBytes = rawCacheBytes
|
||||||
|
if c.Limits.RawCacheMaxEntries <= 0 {
|
||||||
|
return errors.New("limits.raw_cache_max_entries must be > 0")
|
||||||
|
}
|
||||||
|
|
||||||
cleanupInterval, err := time.ParseDuration(strings.TrimSpace(c.Storage.CleanupInterval))
|
cleanupInterval, err := time.ParseDuration(strings.TrimSpace(c.Storage.CleanupInterval))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("storage.cleanup_interval invalid: %w", err)
|
return fmt.Errorf("storage.cleanup_interval invalid: %w", err)
|
||||||
@@ -231,11 +254,9 @@ func parseHumanSize(raw string) (int64, error) {
|
|||||||
"KB": 1_000,
|
"KB": 1_000,
|
||||||
"MB": 1_000_000,
|
"MB": 1_000_000,
|
||||||
"GB": 1_000_000_000,
|
"GB": 1_000_000_000,
|
||||||
"TB": 1_000_000_000_000,
|
|
||||||
"KIB": 1024,
|
"KIB": 1024,
|
||||||
"MIB": 1024 * 1024,
|
"MIB": 1024 * 1024,
|
||||||
"GIB": 1024 * 1024 * 1024,
|
"GIB": 1024 * 1024 * 1024,
|
||||||
"TIB": 1024 * 1024 * 1024 * 1024,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
multiplier, ok := multipliers[unitPart]
|
multiplier, ok := multipliers[unitPart]
|
||||||
|
|||||||
@@ -19,17 +19,26 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
|
|||||||
t.Fatalf("Validate() returned error: %v", err)
|
t.Fatalf("Validate() returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Limits.MaxUploadSizeBytes != 100_000_000 {
|
if cfg.Limits.MaxUploadSizeBytes != 100*1024*1024 {
|
||||||
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(100_000_000))
|
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(100*1024*1024))
|
||||||
}
|
}
|
||||||
if cfg.Limits.DefaultTTLDuration != time.Hour {
|
if cfg.Limits.DefaultTTLDuration != time.Hour {
|
||||||
t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration)
|
t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration)
|
||||||
}
|
}
|
||||||
|
if cfg.Limits.RawCacheMaxBytes != 200*1024*1024 {
|
||||||
|
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(200*1024*1024))
|
||||||
|
}
|
||||||
|
if cfg.Limits.RawCacheMaxEntries != 32 {
|
||||||
|
t.Fatalf("RawCacheMaxEntries = %d, want %d", cfg.Limits.RawCacheMaxEntries, 32)
|
||||||
|
}
|
||||||
if cfg.Storage.CleanupIntervalParsed != time.Minute {
|
if cfg.Storage.CleanupIntervalParsed != time.Minute {
|
||||||
t.Fatalf("CleanupIntervalParsed = %s, want 1m", cfg.Storage.CleanupIntervalParsed)
|
t.Fatalf("CleanupIntervalParsed = %s, want 1m", cfg.Storage.CleanupIntervalParsed)
|
||||||
}
|
}
|
||||||
if len(cfg.Security.AllowedPrefixes) != 0 {
|
if len(cfg.Security.AllowedPrefixes) != 1 {
|
||||||
t.Fatalf("AllowedPrefixes len = %d, want 0", len(cfg.Security.AllowedPrefixes))
|
t.Fatalf("AllowedPrefixes len = %d, want 1", len(cfg.Security.AllowedPrefixes))
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +54,8 @@ server:
|
|||||||
limits:
|
limits:
|
||||||
max_upload_size: "2MiB"
|
max_upload_size: "2MiB"
|
||||||
default_ttl: "2h"
|
default_ttl: "2h"
|
||||||
|
raw_cache_max_size: "16MiB"
|
||||||
|
raw_cache_max_entries: 5
|
||||||
storage:
|
storage:
|
||||||
data_dir: "` + dataDir + `"
|
data_dir: "` + dataDir + `"
|
||||||
cleanup_interval: "45s"
|
cleanup_interval: "45s"
|
||||||
@@ -76,6 +87,12 @@ security:
|
|||||||
if cfg.Limits.DefaultTTLDuration != 2*time.Hour {
|
if cfg.Limits.DefaultTTLDuration != 2*time.Hour {
|
||||||
t.Fatalf("DefaultTTLDuration = %s, want 2h", cfg.Limits.DefaultTTLDuration)
|
t.Fatalf("DefaultTTLDuration = %s, want 2h", cfg.Limits.DefaultTTLDuration)
|
||||||
}
|
}
|
||||||
|
if cfg.Limits.RawCacheMaxBytes != 16*1024*1024 {
|
||||||
|
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(16*1024*1024))
|
||||||
|
}
|
||||||
|
if cfg.Limits.RawCacheMaxEntries != 5 {
|
||||||
|
t.Fatalf("RawCacheMaxEntries = %d, want %d", cfg.Limits.RawCacheMaxEntries, 5)
|
||||||
|
}
|
||||||
if cfg.Storage.CleanupIntervalParsed != 45*time.Second {
|
if cfg.Storage.CleanupIntervalParsed != 45*time.Second {
|
||||||
t.Fatalf("CleanupIntervalParsed = %s, want 45s", cfg.Storage.CleanupIntervalParsed)
|
t.Fatalf("CleanupIntervalParsed = %s, want 45s", cfg.Storage.CleanupIntervalParsed)
|
||||||
}
|
}
|
||||||
@@ -119,6 +136,20 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantErr: "limits.default_ttl",
|
wantErr: "limits.default_ttl",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "invalid raw cache size",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Limits.RawCacheMaxSize = "oops"
|
||||||
|
},
|
||||||
|
wantErr: "limits.raw_cache_max_size",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid raw cache max entries",
|
||||||
|
mutate: func(cfg *Config) {
|
||||||
|
cfg.Limits.RawCacheMaxEntries = 0
|
||||||
|
},
|
||||||
|
wantErr: "limits.raw_cache_max_entries",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "ttl above max",
|
name: "ttl above max",
|
||||||
mutate: func(cfg *Config) {
|
mutate: func(cfg *Config) {
|
||||||
@@ -236,6 +267,8 @@ func TestParseHumanSizeVariants(t *testing.T) {
|
|||||||
{in: "10", want: 10},
|
{in: "10", want: 10},
|
||||||
{in: "1.5KB", want: 1500},
|
{in: "1.5KB", want: 1500},
|
||||||
{in: "2MiB", want: 2 * 1024 * 1024},
|
{in: "2MiB", want: 2 * 1024 * 1024},
|
||||||
|
{in: "1TB", wantErr: "unsupported unit"},
|
||||||
|
{in: "1TiB", wantErr: "unsupported unit"},
|
||||||
{in: "0", wantErr: "size must be > 0"},
|
{in: "0", wantErr: "size must be > 0"},
|
||||||
{in: "abc", wantErr: "does not start with a number"},
|
{in: "abc", wantErr: "does not start with a number"},
|
||||||
{in: "5XB", wantErr: "unsupported unit"},
|
{in: "5XB", wantErr: "unsupported unit"},
|
||||||
|
|||||||
+122
-103
@@ -1,64 +1,46 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"scratchbox/internal/config"
|
"scratchbox/internal/config"
|
||||||
"scratchbox/internal/storage"
|
"scratchbox/internal/storage"
|
||||||
|
webassets "scratchbox/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxViewPreviewBytes int64 = 2 * 1024 * 1024
|
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
store *storage.FilesystemStore
|
store *storage.FilesystemStore
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
templates *template.Template
|
rawCache *rawContentCache
|
||||||
}
|
|
||||||
|
|
||||||
type templateViewData struct {
|
|
||||||
ID string
|
|
||||||
Content string
|
|
||||||
ContentType string
|
|
||||||
RawURL string
|
|
||||||
ExpiresAt string
|
|
||||||
Truncated bool
|
|
||||||
Binary bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger) (*Server, error) {
|
func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger) (*Server, error) {
|
||||||
tmpl, err := template.ParseFiles(
|
|
||||||
filepath.Join("web", "templates", "index.html"),
|
|
||||||
filepath.Join("web", "templates", "view.html"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse templates: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
templates: tmpl,
|
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Routes() http.Handler {
|
func (s *Server) Routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.Handle("GET /", http.HandlerFunc(s.index))
|
mux.Handle("GET /{$}", http.HandlerFunc(s.serveSPA))
|
||||||
|
mux.Handle("GET /u", http.HandlerFunc(s.serveSPA))
|
||||||
|
mux.Handle("GET /api/config", http.HandlerFunc(s.getUIConfig))
|
||||||
mux.Handle("GET /api/scratch/{id}", http.HandlerFunc(s.getScratch))
|
mux.Handle("GET /api/scratch/{id}", http.HandlerFunc(s.getScratch))
|
||||||
mux.Handle("GET /s/{id}", http.HandlerFunc(s.viewScratch))
|
mux.Handle("GET /s/{id}", http.HandlerFunc(s.scratchPage))
|
||||||
mux.Handle("GET /raw/{id}", http.HandlerFunc(s.rawScratch))
|
mux.Handle("GET /api/raw/{id}", http.HandlerFunc(s.rawScratch))
|
||||||
|
|
||||||
writeHandler := http.HandlerFunc(s.createScratch)
|
writeHandler := http.HandlerFunc(s.createScratch)
|
||||||
middlewares := make([]func(http.Handler) http.Handler, 0, 2)
|
middlewares := make([]func(http.Handler) http.Handler, 0, 2)
|
||||||
@@ -69,20 +51,69 @@ func (s *Server) Routes() http.Handler {
|
|||||||
}
|
}
|
||||||
mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...))
|
mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...))
|
||||||
|
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join("web", "static")))))
|
mux.Handle("GET /assets/", http.FileServerFS(webassets.StaticFS()))
|
||||||
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(webassets.StaticFS())))
|
||||||
|
mux.Handle("GET /{path...}", http.HandlerFunc(s.notFoundPage))
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.templates.ExecuteTemplate(w, "index.html", nil); err != nil {
|
s.serveSPAWithStatus(w, http.StatusOK)
|
||||||
http.Error(w, "failed to render page", http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"max_upload_size_bytes": s.cfg.Limits.MaxUploadSizeBytes,
|
||||||
|
"upload_allowed": s.isUploadAllowedForRequest(r),
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) notFoundPage(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
s.serveSPAWithStatus(w, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) expiredScratchPage(w http.ResponseWriter) {
|
||||||
|
s.serveSPAWithStatus(w, http.StatusGone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveSPAWithStatus(w http.ResponseWriter, status int) {
|
||||||
|
b, err := fs.ReadFile(webassets.StaticFS(), "index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to render page", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) scratchPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := strings.TrimSpace(r.PathValue("id"))
|
||||||
|
if id == "" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := s.store.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
s.expiredScratchPage(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.isExpired(meta) {
|
||||||
|
_ = s.store.Delete(id)
|
||||||
|
s.expiredScratchPage(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.serveSPA(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxUploadSizeBytes)
|
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxUploadSizeBytes)
|
||||||
|
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
|
originalName := ""
|
||||||
contentType := strings.TrimSpace(r.Header.Get("Content-Type"))
|
contentType := strings.TrimSpace(r.Header.Get("Content-Type"))
|
||||||
|
|
||||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||||
@@ -102,7 +133,12 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "multipart request must include either file or content, not both", http.StatusBadRequest)
|
http.Error(w, "multipart request must include either file or content, not both", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if header.Size > s.cfg.Limits.MaxUploadSizeBytes {
|
||||||
|
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
reader = file
|
reader = file
|
||||||
|
originalName = header.Filename
|
||||||
contentType = header.Header.Get("Content-Type")
|
contentType = header.Header.Get("Content-Type")
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
contentType = "application/octet-stream"
|
contentType = "application/octet-stream"
|
||||||
@@ -123,10 +159,10 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
meta, err := s.store.Create(r.Context(), reader, contentType, s.cfg.Limits.DefaultTTLDuration)
|
meta, err := s.store.CreateWithOriginalName(r.Context(), reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration)
|
||||||
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, "upload exceeds limits.max_upload_size", http.StatusRequestEntityTooLarge)
|
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "failed to save scratch", http.StatusInternalServerError)
|
http.Error(w, "failed to save scratch", http.StatusInternalServerError)
|
||||||
@@ -139,8 +175,9 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
|||||||
"expires_at": meta.ExpiresAt,
|
"expires_at": meta.ExpiresAt,
|
||||||
"size": meta.Size,
|
"size": meta.Size,
|
||||||
"content_type": meta.ContentType,
|
"content_type": meta.ContentType,
|
||||||
|
"filename": meta.OriginalName,
|
||||||
"view_url": fmt.Sprintf("/s/%s", meta.ID),
|
"view_url": fmt.Sprintf("/s/%s", meta.ID),
|
||||||
"raw_url": fmt.Sprintf("/raw/%s", meta.ID),
|
"raw_url": fmt.Sprintf("/api/raw/%s", meta.ID),
|
||||||
"api_url": fmt.Sprintf("/api/scratch/%s", meta.ID),
|
"api_url": fmt.Sprintf("/api/scratch/%s", meta.ID),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, payload)
|
writeJSON(w, http.StatusCreated, payload)
|
||||||
@@ -166,7 +203,7 @@ func (s *Server) getScratch(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
meta, err := s.store.Get(id)
|
meta, err := s.store.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.Error(w, "scratch gone", http.StatusGone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.isExpired(meta) {
|
if s.isExpired(meta) {
|
||||||
@@ -181,91 +218,73 @@ func (s *Server) getScratch(w http.ResponseWriter, r *http.Request) {
|
|||||||
"expires_at": meta.ExpiresAt,
|
"expires_at": meta.ExpiresAt,
|
||||||
"size": meta.Size,
|
"size": meta.Size,
|
||||||
"content_type": meta.ContentType,
|
"content_type": meta.ContentType,
|
||||||
|
"filename": meta.OriginalName,
|
||||||
"view_url": fmt.Sprintf("/s/%s", meta.ID),
|
"view_url": fmt.Sprintf("/s/%s", meta.ID),
|
||||||
"raw_url": fmt.Sprintf("/raw/%s", meta.ID),
|
"raw_url": fmt.Sprintf("/api/raw/%s", meta.ID),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, payload)
|
writeJSON(w, http.StatusOK, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) viewScratch(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
|
||||||
file, meta, err := s.store.Open(id)
|
|
||||||
if err != nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
if s.isExpired(meta) {
|
|
||||||
_ = s.store.Delete(id)
|
|
||||||
http.Error(w, "scratch expired", http.StatusGone)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
limited := io.LimitReader(file, maxViewPreviewBytes+1)
|
|
||||||
b, err := io.ReadAll(limited)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "failed to read scratch", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
truncated := int64(len(b)) > maxViewPreviewBytes
|
|
||||||
if truncated {
|
|
||||||
b = b[:maxViewPreviewBytes]
|
|
||||||
}
|
|
||||||
isBinary := !isLikelyText(meta.ContentType, b)
|
|
||||||
|
|
||||||
data := templateViewData{
|
|
||||||
ID: meta.ID,
|
|
||||||
ContentType: meta.ContentType,
|
|
||||||
RawURL: fmt.Sprintf("/raw/%s", meta.ID),
|
|
||||||
ExpiresAt: meta.ExpiresAt.Format("2006-01-02 15:04:05 MST"),
|
|
||||||
Truncated: truncated,
|
|
||||||
Binary: isBinary,
|
|
||||||
}
|
|
||||||
if !isBinary {
|
|
||||||
data.Content = string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.templates.ExecuteTemplate(w, "view.html", data); err != nil {
|
|
||||||
http.Error(w, "failed to render scratch view", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
|
||||||
id := strings.TrimSpace(r.PathValue("id"))
|
id := strings.TrimSpace(r.PathValue("id"))
|
||||||
file, meta, err := s.store.Open(id)
|
meta, err := s.store.Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
s.rawCache.Delete(id)
|
||||||
|
w.WriteHeader(http.StatusGone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
if s.isExpired(meta) {
|
if s.isExpired(meta) {
|
||||||
_ = s.store.Delete(id)
|
_ = s.store.Delete(id)
|
||||||
http.Error(w, "scratch expired", http.StatusGone)
|
s.rawCache.Delete(id)
|
||||||
|
w.WriteHeader(http.StatusGone)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if meta.ContentType == "" {
|
if meta.ContentType == "" {
|
||||||
meta.ContentType = "application/octet-stream"
|
meta.ContentType = "application/octet-stream"
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", meta.ContentType)
|
if meta.OriginalName != "" {
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", meta.Size))
|
disposition := fmt.Sprintf(`attachment; filename=%q`, meta.OriginalName)
|
||||||
if _, err := io.Copy(w, file); err != nil {
|
if formatted := mime.FormatMediaType("attachment", map[string]string{"filename": meta.OriginalName}); formatted != "" {
|
||||||
s.logger.Printf("failed to stream raw scratch id=%s err=%v", id, err)
|
disposition = formatted
|
||||||
}
|
}
|
||||||
|
w.Header().Set("Content-Disposition", disposition)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", meta.ContentType)
|
||||||
|
content, err, _ := s.rawCache.GetOrLoad(meta.ID, func() ([]byte, error) {
|
||||||
|
file, _, openErr := s.store.Open(meta.ID)
|
||||||
|
if openErr != nil {
|
||||||
|
return nil, openErr
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return io.ReadAll(file)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrNotFound) {
|
||||||
|
s.rawCache.Delete(id)
|
||||||
|
w.WriteHeader(http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "failed to read scratch", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.ServeContent(w, r, meta.ID, meta.CreatedAt, bytes.NewReader(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
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") {
|
||||||
http.Error(w, "upload exceeds limits.max_upload_size", http.StatusRequestEntityTooLarge)
|
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "invalid upload payload", http.StatusBadRequest)
|
http.Error(w, "invalid upload payload", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) maxUploadSizeError() string {
|
||||||
|
return fmt.Sprintf("upload exceeds limits.max_upload_size (%d bytes)", s.cfg.Limits.MaxUploadSizeBytes)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) isExpired(meta storage.Metadata) bool {
|
func (s *Server) isExpired(meta storage.Metadata) bool {
|
||||||
return !meta.ExpiresAt.After(nowUTC())
|
return !meta.ExpiresAt.After(nowUTC())
|
||||||
}
|
}
|
||||||
@@ -274,21 +293,21 @@ func nowUTC() time.Time {
|
|||||||
return time.Now().UTC()
|
return time.Now().UTC()
|
||||||
}
|
}
|
||||||
|
|
||||||
func isLikelyText(contentType string, content []byte) bool {
|
func (s *Server) isUploadAllowedForRequest(r *http.Request) bool {
|
||||||
if ct := strings.TrimSpace(contentType); ct != "" {
|
if len(s.cfg.Security.AllowedPrefixes) == 0 {
|
||||||
mediaType, _, err := mime.ParseMediaType(ct)
|
|
||||||
if err == nil {
|
|
||||||
if strings.HasPrefix(mediaType, "text/") {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
switch mediaType {
|
|
||||||
case "application/json", "application/xml", "application/javascript", "application/x-www-form-urlencoded":
|
clientIP, ok := extractClientIP(r, s.cfg.Security.TrustProxyHeaders)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, prefix := range s.cfg.Security.AllowedPrefixes {
|
||||||
|
if prefix.Contains(clientIP) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if len(content) == 0 {
|
return false
|
||||||
return true
|
|
||||||
}
|
|
||||||
return utf8.Valid(content)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html/template"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"mime"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ func TestCreateReadThenExpireScratch(t *testing.T) {
|
|||||||
t.Fatalf("read metadata status = %d, want %d", metaRec.Code, http.StatusOK)
|
t.Fatalf("read metadata status = %d, want %d", metaRec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
rawReq := httptest.NewRequest(http.MethodGet, "/raw/"+id, nil)
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||||
rawReq.RemoteAddr = "127.0.0.1:9999"
|
rawReq.RemoteAddr = "127.0.0.1:9999"
|
||||||
rawRec := httptest.NewRecorder()
|
rawRec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rawRec, rawReq)
|
handler.ServeHTTP(rawRec, rawReq)
|
||||||
@@ -76,6 +77,80 @@ func TestCreateReadThenExpireScratch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 TestCreateScratchRejectsBodyOverLimit(t *testing.T) {
|
func TestCreateScratchRejectsBodyOverLimit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -97,6 +172,48 @@ func TestCreateScratchRejectsBodyOverLimit(t *testing.T) {
|
|||||||
if !bytes.Contains(rec.Body.Bytes(), []byte("upload exceeds limits.max_upload_size")) {
|
if !bytes.Contains(rec.Body.Bytes(), []byte("upload exceeds limits.max_upload_size")) {
|
||||||
t.Fatalf("unexpected body: %q", rec.Body.String())
|
t.Fatalf("unexpected body: %q", rec.Body.String())
|
||||||
}
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte("(8 bytes)")) {
|
||||||
|
t.Fatalf("expected response to include configured max size, got: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateScratchRejectsMultipartFileOverLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := newTestHandler(t, testServerOptions{
|
||||||
|
maxUploadBytes: 8,
|
||||||
|
defaultTTL: time.Hour,
|
||||||
|
rateLimitEnable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
file, err := writer.CreateFormFile("file", "too-big.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write([]byte("this is too large")); err != nil {
|
||||||
|
t.Fatalf("write file error = %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("multipart close error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
req.RemoteAddr = "127.0.0.1:5060"
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte("upload exceeds limits.max_upload_size")) {
|
||||||
|
t.Fatalf("unexpected body: %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte("(8 bytes)")) {
|
||||||
|
t.Fatalf("expected response to include configured max size, got: %q", rec.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateScratchRejectsMultipleMultipartFiles(t *testing.T) {
|
func TestCreateScratchRejectsMultipleMultipartFiles(t *testing.T) {
|
||||||
@@ -171,6 +288,70 @@ func TestCreateScratchMultipartContentOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateScratchMultipartPreservesFilenameForRawDownload(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := newTestHandler(t, testServerOptions{
|
||||||
|
maxUploadBytes: 1024,
|
||||||
|
defaultTTL: time.Hour,
|
||||||
|
rateLimitEnable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
file, err := writer.CreateFormFile("file", "example upload.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateFormFile() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := file.Write([]byte("uploaded file body")); err != nil {
|
||||||
|
t.Fatalf("write file error = %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("multipart close error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||||
|
createReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
createReq.RemoteAddr = "127.0.0.1:5058"
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(createRec, createReq)
|
||||||
|
if createRec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("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")
|
||||||
|
}
|
||||||
|
|
||||||
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||||
|
rawReq.RemoteAddr = "127.0.0.1:5059"
|
||||||
|
rawRec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rawRec, rawReq)
|
||||||
|
if rawRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := rawRec.Body.String(); got != "uploaded file body" {
|
||||||
|
t.Fatalf("raw body = %q, want %q", got, "uploaded file body")
|
||||||
|
}
|
||||||
|
|
||||||
|
disposition := rawRec.Header().Get("Content-Disposition")
|
||||||
|
if disposition == "" {
|
||||||
|
t.Fatalf("expected Content-Disposition header")
|
||||||
|
}
|
||||||
|
_, params, err := mime.ParseMediaType(disposition)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMediaType() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := params["filename"]; got != "example upload.txt" {
|
||||||
|
t.Fatalf("filename = %q, want %q", got, "example upload.txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateScratchMultipartRejectsFileAndContent(t *testing.T) {
|
func TestCreateScratchMultipartRejectsFileAndContent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -232,7 +413,7 @@ func TestCreateScratchMultipartRequiresFileOrContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetScratchNotFound(t *testing.T) {
|
func TestGetScratchNotFoundReturnsGone(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
handler := newTestHandler(t, testServerOptions{
|
handler := newTestHandler(t, testServerOptions{
|
||||||
@@ -245,8 +426,8 @@ func TestGetScratchNotFound(t *testing.T) {
|
|||||||
req.RemoteAddr = "127.0.0.1:5055"
|
req.RemoteAddr = "127.0.0.1:5055"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusGone {
|
||||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusGone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,7 +445,7 @@ func TestRawScratchDefaultsContentType(t *testing.T) {
|
|||||||
t.Fatalf("store.Create() error = %v", err)
|
t.Fatalf("store.Create() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/raw/"+meta.ID, nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil)
|
||||||
req.RemoteAddr = "127.0.0.1:5056"
|
req.RemoteAddr = "127.0.0.1:5056"
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
@@ -276,10 +457,93 @@ func TestRawScratchDefaultsContentType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestViewScratchAndIndexRender(t *testing.T) {
|
func TestRawScratchSupportsRangeRequests(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
handler, store := newTemplateHandlerAndStore(t, testServerOptions{
|
handler := newTestHandler(t, testServerOptions{
|
||||||
|
maxUploadBytes: 1024,
|
||||||
|
defaultTTL: time.Hour,
|
||||||
|
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:5061"
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||||
|
rangeReq.Header.Set("Range", "bytes=0-4")
|
||||||
|
rangeReq.RemoteAddr = "127.0.0.1:5062"
|
||||||
|
rangeRec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rangeRec, rangeReq)
|
||||||
|
if rangeRec.Code != http.StatusPartialContent {
|
||||||
|
t.Fatalf("range status = %d, want %d", rangeRec.Code, http.StatusPartialContent)
|
||||||
|
}
|
||||||
|
if got := rangeRec.Body.String(); got != "hello" {
|
||||||
|
t.Fatalf("range body = %q, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
if got := rangeRec.Header().Get("Content-Range"); got != "bytes 0-4/13" {
|
||||||
|
t.Fatalf("Content-Range = %q, want %q", got, "bytes 0-4/13")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawScratchRejectsUnsatisfiableRange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler := newTestHandler(t, testServerOptions{
|
||||||
|
maxUploadBytes: 1024,
|
||||||
|
defaultTTL: time.Hour,
|
||||||
|
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:5063"
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||||
|
rangeReq.Header.Set("Range", "bytes=999-1000")
|
||||||
|
rangeReq.RemoteAddr = "127.0.0.1:5064"
|
||||||
|
rangeRec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rangeRec, rangeReq)
|
||||||
|
if rangeRec.Code != http.StatusRequestedRangeNotSatisfiable {
|
||||||
|
t.Fatalf("range status = %d, want %d", rangeRec.Code, http.StatusRequestedRangeNotSatisfiable)
|
||||||
|
}
|
||||||
|
if got := rangeRec.Header().Get("Content-Range"); got != "bytes */13" {
|
||||||
|
t.Fatalf("Content-Range = %q, want %q", got, "bytes */13")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewUploadAndIndexRender(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
||||||
maxUploadBytes: 10 * 1024 * 1024,
|
maxUploadBytes: 10 * 1024 * 1024,
|
||||||
defaultTTL: time.Hour,
|
defaultTTL: time.Hour,
|
||||||
rateLimitEnable: false,
|
rateLimitEnable: false,
|
||||||
@@ -296,6 +560,22 @@ func TestViewScratchAndIndexRender(t *testing.T) {
|
|||||||
if indexRec.Code != http.StatusOK {
|
if indexRec.Code != http.StatusOK {
|
||||||
t.Fatalf("index status = %d, want %d", indexRec.Code, http.StatusOK)
|
t.Fatalf("index status = %d, want %d", indexRec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(indexRec.Body.String(), `id="app"`) {
|
||||||
|
t.Fatalf("expected index page to include SPA mount point")
|
||||||
|
}
|
||||||
|
if !strings.Contains(indexRec.Body.String(), `type="module"`) {
|
||||||
|
t.Fatalf("expected index page to load frontend bundle")
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadReq := httptest.NewRequest(http.MethodGet, "/u", nil)
|
||||||
|
uploadRec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(uploadRec, uploadReq)
|
||||||
|
if uploadRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("upload status = %d, want %d", uploadRec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if !strings.Contains(uploadRec.Body.String(), `id="app"`) {
|
||||||
|
t.Fatalf("expected upload page to include SPA mount point")
|
||||||
|
}
|
||||||
|
|
||||||
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+meta.ID, nil)
|
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+meta.ID, nil)
|
||||||
viewRec := httptest.NewRecorder()
|
viewRec := httptest.NewRecorder()
|
||||||
@@ -303,15 +583,15 @@ func TestViewScratchAndIndexRender(t *testing.T) {
|
|||||||
if viewRec.Code != http.StatusOK {
|
if viewRec.Code != http.StatusOK {
|
||||||
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusOK)
|
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
if !strings.Contains(viewRec.Body.String(), "hello view") {
|
if !strings.Contains(viewRec.Body.String(), `id="app"`) {
|
||||||
t.Fatalf("expected view body to include scratch content")
|
t.Fatalf("expected scratch route to render SPA shell")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestViewAndRawScratchExpired(t *testing.T) {
|
func TestViewAndRawScratchExpired(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
handler, store := newTemplateHandlerAndStore(t, testServerOptions{
|
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
||||||
maxUploadBytes: 1024,
|
maxUploadBytes: 1024,
|
||||||
defaultTTL: time.Hour,
|
defaultTTL: time.Hour,
|
||||||
rateLimitEnable: false,
|
rateLimitEnable: false,
|
||||||
@@ -328,11 +608,99 @@ func TestViewAndRawScratchExpired(t *testing.T) {
|
|||||||
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone)
|
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone)
|
||||||
}
|
}
|
||||||
|
|
||||||
rawReq := httptest.NewRequest(http.MethodGet, "/raw/"+meta.ID, nil)
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil)
|
||||||
rawRec := httptest.NewRecorder()
|
rawRec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rawRec, rawReq)
|
handler.ServeHTTP(rawRec, rawReq)
|
||||||
if rawRec.Code != http.StatusNotFound {
|
if rawRec.Code != http.StatusGone {
|
||||||
t.Fatalf("raw status after lazy delete = %d, want %d", rawRec.Code, http.StatusNotFound)
|
t.Fatalf("raw status after lazy delete = %d, want %d", rawRec.Code, http.StatusGone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawScratchExpiredReturnsStatusOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
||||||
|
maxUploadBytes: 1024,
|
||||||
|
defaultTTL: time.Hour,
|
||||||
|
rateLimitEnable: false,
|
||||||
|
})
|
||||||
|
meta, err := store.Create(t.Context(), strings.NewReader("expired body"), "text/plain", -time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, 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/does-not-exist", nil)
|
||||||
|
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 TestNeverUploadedScratchIDsAlsoReturnGone(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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +723,263 @@ func TestCreateScratchRejectsMalformedMultipart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type testServerOptions struct {
|
type testServerOptions struct {
|
||||||
maxUploadBytes int64
|
maxUploadBytes int64
|
||||||
defaultTTL time.Duration
|
defaultTTL time.Duration
|
||||||
@@ -384,6 +1009,8 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
|
|||||||
Limits: config.LimitsConfig{
|
Limits: config.LimitsConfig{
|
||||||
MaxUploadSizeBytes: opts.maxUploadBytes,
|
MaxUploadSizeBytes: opts.maxUploadBytes,
|
||||||
DefaultTTLDuration: opts.defaultTTL,
|
DefaultTTLDuration: opts.defaultTTL,
|
||||||
|
RawCacheMaxBytes: 64 * 1024 * 1024,
|
||||||
|
RawCacheMaxEntries: 32,
|
||||||
},
|
},
|
||||||
Storage: config.StorageConfig{
|
Storage: config.StorageConfig{
|
||||||
DataDir: dataDir,
|
DataDir: dataDir,
|
||||||
@@ -401,54 +1028,7 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: store,
|
store: store,
|
||||||
logger: log.New(io.Discard, "", 0),
|
logger: log.New(io.Discard, "", 0),
|
||||||
templates: nil,
|
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
|
||||||
}
|
|
||||||
return srv.Routes(), store
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTemplateHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, *storage.FilesystemStore) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
handler, store := newTestHandlerAndStore(t, opts)
|
|
||||||
_ = handler // keep shared setup path
|
|
||||||
|
|
||||||
_, filePath, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(filePath), "..", ".."))
|
|
||||||
tmpl, err := template.ParseFiles(
|
|
||||||
filepath.Join(repoRoot, "web", "templates", "index.html"),
|
|
||||||
filepath.Join(repoRoot, "web", "templates", "view.html"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseFiles() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := config.Config{
|
|
||||||
Server: config.ServerConfig{
|
|
||||||
ListenAddr: ":0",
|
|
||||||
},
|
|
||||||
Limits: config.LimitsConfig{
|
|
||||||
MaxUploadSizeBytes: opts.maxUploadBytes,
|
|
||||||
DefaultTTLDuration: opts.defaultTTL,
|
|
||||||
},
|
|
||||||
Storage: config.StorageConfig{
|
|
||||||
DataDir: filepath.Join(t.TempDir(), "unused"),
|
|
||||||
},
|
|
||||||
Security: config.SecurityConfig{
|
|
||||||
RateLimit: config.RateLimitConfig{
|
|
||||||
Enabled: opts.rateLimitEnable,
|
|
||||||
RequestsPerMinute: 120,
|
|
||||||
Burst: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
srv := &Server{
|
|
||||||
cfg: cfg,
|
|
||||||
store: store,
|
|
||||||
logger: log.New(io.Discard, "", 0),
|
|
||||||
templates: tmpl,
|
|
||||||
}
|
}
|
||||||
return srv.Routes(), store
|
return srv.Routes(), store
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ package httpapi
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"html/template"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"net/netip"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"scratchbox/internal/config"
|
"scratchbox/internal/config"
|
||||||
@@ -48,27 +47,7 @@ func TestWriteCreateErrorBranches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsLikelyText(t *testing.T) {
|
func TestNewServerInitializes(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
if !isLikelyText("text/plain; charset=utf-8", []byte{0x00}) {
|
|
||||||
t.Fatalf("text/plain should be treated as text")
|
|
||||||
}
|
|
||||||
if !isLikelyText("application/json", []byte{0x00}) {
|
|
||||||
t.Fatalf("application/json should be treated as text")
|
|
||||||
}
|
|
||||||
if isLikelyText("application/octet-stream", []byte{0xff, 0xfe, 0xfd}) {
|
|
||||||
t.Fatalf("binary octet-stream should not be treated as text")
|
|
||||||
}
|
|
||||||
if !isLikelyText("", []byte("hello")) {
|
|
||||||
t.Fatalf("utf8 payload should be treated as text")
|
|
||||||
}
|
|
||||||
if !isLikelyText("", nil) {
|
|
||||||
t.Fatalf("empty payload should be treated as text")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewServerLoadsTemplates(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
|
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -81,32 +60,63 @@ func TestNewServerLoadsTemplates(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, file, _, ok := runtime.Caller(0)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("runtime.Caller failed")
|
|
||||||
}
|
|
||||||
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
|
||||||
wd, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Getwd() error = %v", err)
|
|
||||||
}
|
|
||||||
if err := os.Chdir(repoRoot); err != nil {
|
|
||||||
t.Fatalf("Chdir() error = %v", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = os.Chdir(wd)
|
|
||||||
}()
|
|
||||||
|
|
||||||
srv, err := NewServer(cfg, store, log.New(io.Discard, "", 0))
|
srv, err := NewServer(cfg, store, log.New(io.Discard, "", 0))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewServer() error = %v", err)
|
t.Fatalf("NewServer() error = %v", err)
|
||||||
}
|
}
|
||||||
if srv == nil || srv.templates == nil {
|
if srv == nil {
|
||||||
t.Fatalf("expected initialized server with templates")
|
t.Fatalf("expected initialized server")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewServerTemplateErrorAndIndexError(t *testing.T) {
|
func TestGetUIConfig(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
cfg: config.Config{
|
||||||
|
Limits: config.LimitsConfig{
|
||||||
|
MaxUploadSizeBytes: 2048,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||||
|
s.getUIConfig(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`) {
|
||||||
|
t.Fatalf("unexpected body: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUIConfigUploadDenied(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
cfg: config.Config{
|
||||||
|
Limits: config.LimitsConfig{
|
||||||
|
MaxUploadSizeBytes: 2048,
|
||||||
|
},
|
||||||
|
Security: config.SecurityConfig{
|
||||||
|
AllowedPrefixes: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.0.0.0/8"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||||
|
s.getUIConfig(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if got := rec.Body.String(); got == "" || !containsAll(got, `"upload_allowed":false`) {
|
||||||
|
t.Fatalf("unexpected body: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteCreateErrorBranchesWithConfig(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
|
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -118,44 +128,16 @@ func TestNewServerTemplateErrorAndIndexError(t *testing.T) {
|
|||||||
RateLimit: config.RateLimitConfig{Enabled: false},
|
RateLimit: config.RateLimitConfig{Enabled: false},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
wd, err := os.Getwd()
|
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0)); err != nil {
|
||||||
if err != nil {
|
t.Fatalf("NewServer() error = %v", err)
|
||||||
t.Fatalf("Getwd() error = %v", err)
|
|
||||||
}
|
}
|
||||||
if err := os.Chdir(tmp); err != nil {
|
|
||||||
t.Fatalf("Chdir() error = %v", err)
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = os.Chdir(wd)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0)); err == nil {
|
|
||||||
t.Fatalf("expected NewServer() template parse error")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s := &Server{}
|
func containsAll(haystack string, needles ...string) bool {
|
||||||
func() {
|
for _, needle := range needles {
|
||||||
defer func() {
|
if !strings.Contains(haystack, needle) {
|
||||||
if recover() == nil {
|
return false
|
||||||
t.Fatalf("expected panic when templates are nil")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
s.index(rec, req)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIndexExecuteTemplateError(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := &Server{
|
|
||||||
templates: template.New("empty"),
|
|
||||||
}
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
s.index(rec, req)
|
|
||||||
if rec.Code != http.StatusInternalServerError {
|
|
||||||
t.Fatalf("index status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultRawCacheMaxEntries = 32
|
||||||
|
defaultRawCacheMaxBytes = 64 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
type rawContentCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
maxEntries int
|
||||||
|
maxBytes int64
|
||||||
|
totalBytes int64
|
||||||
|
items map[string]*list.Element
|
||||||
|
lru *list.List
|
||||||
|
inFlight map[string]*rawContentLoad
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawContentItem struct {
|
||||||
|
id string
|
||||||
|
data []byte
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawContentLoad struct {
|
||||||
|
wait chan struct{}
|
||||||
|
data []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRawContentCache(maxEntries int, maxBytes int64) *rawContentCache {
|
||||||
|
if maxEntries <= 0 {
|
||||||
|
maxEntries = defaultRawCacheMaxEntries
|
||||||
|
}
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = defaultRawCacheMaxBytes
|
||||||
|
}
|
||||||
|
return &rawContentCache{
|
||||||
|
maxEntries: maxEntries,
|
||||||
|
maxBytes: maxBytes,
|
||||||
|
items: make(map[string]*list.Element, maxEntries),
|
||||||
|
lru: list.New(),
|
||||||
|
inFlight: make(map[string]*rawContentLoad),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rawContentCache) GetOrLoad(id string, loader func() ([]byte, error)) ([]byte, error, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if elem, ok := c.items[id]; ok {
|
||||||
|
c.lru.MoveToFront(elem)
|
||||||
|
item := elem.Value.(*rawContentItem)
|
||||||
|
data := 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
|
||||||
|
}
|
||||||
|
|
||||||
|
load := &rawContentLoad{wait: make(chan struct{})}
|
||||||
|
c.inFlight[id] = load
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := loader()
|
||||||
|
load.data = data
|
||||||
|
load.err = err
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.inFlight, id)
|
||||||
|
if err == nil {
|
||||||
|
c.setLocked(id, data)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
close(load.wait)
|
||||||
|
return data, err, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rawContentCache) Delete(id string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.deleteLocked(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rawContentCache) setLocked(id string, data []byte) {
|
||||||
|
size := int64(len(data))
|
||||||
|
if size <= 0 || size > c.maxBytes {
|
||||||
|
c.deleteLocked(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if elem, ok := c.items[id]; ok {
|
||||||
|
item := elem.Value.(*rawContentItem)
|
||||||
|
c.totalBytes -= item.size
|
||||||
|
item.data = data
|
||||||
|
item.size = size
|
||||||
|
c.totalBytes += size
|
||||||
|
c.lru.MoveToFront(elem)
|
||||||
|
c.evictLocked()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &rawContentItem{
|
||||||
|
id: id,
|
||||||
|
data: data,
|
||||||
|
size: size,
|
||||||
|
}
|
||||||
|
elem := c.lru.PushFront(item)
|
||||||
|
c.items[id] = elem
|
||||||
|
c.totalBytes += size
|
||||||
|
c.evictLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rawContentCache) deleteLocked(id string) {
|
||||||
|
elem, ok := c.items[id]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := elem.Value.(*rawContentItem)
|
||||||
|
c.totalBytes -= item.size
|
||||||
|
delete(c.items, id)
|
||||||
|
c.lru.Remove(elem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rawContentCache) evictLocked() {
|
||||||
|
for c.totalBytes > c.maxBytes || len(c.items) > c.maxEntries {
|
||||||
|
last := c.lru.Back()
|
||||||
|
if last == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := last.Value.(*rawContentItem)
|
||||||
|
c.totalBytes -= item.size
|
||||||
|
delete(c.items, item.id)
|
||||||
|
c.lru.Remove(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRawContentCacheCoalescesConcurrentLoads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := newRawContentCache(8, 8*1024*1024)
|
||||||
|
var loads int32
|
||||||
|
want := []byte("coalesced")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
got, err, _ := cache.GetOrLoad("shared-id", func() ([]byte, error) {
|
||||||
|
atomic.AddInt32(&loads, 1)
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
return want, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetOrLoad() error = %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("GetOrLoad() content mismatch = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := atomic.LoadInt32(&loads); got != 1 {
|
||||||
|
t.Fatalf("loader call count = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRawContentCacheEvictionCausesReload(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache := newRawContentCache(1, 3)
|
||||||
|
var loadsA int32
|
||||||
|
var loadsB int32
|
||||||
|
|
||||||
|
if _, err, _ := cache.GetOrLoad("a", func() ([]byte, error) {
|
||||||
|
atomic.AddInt32(&loadsA, 1)
|
||||||
|
return []byte("aaa"), nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("GetOrLoad(a) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err, _ := cache.GetOrLoad("b", func() ([]byte, error) {
|
||||||
|
atomic.AddInt32(&loadsB, 1)
|
||||||
|
return []byte("bbb"), nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("GetOrLoad(b) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err, _ := cache.GetOrLoad("a", func() ([]byte, error) {
|
||||||
|
atomic.AddInt32(&loadsA, 1)
|
||||||
|
return []byte("aaa"), nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("GetOrLoad(a second) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := atomic.LoadInt32(&loadsB); got != 1 {
|
||||||
|
t.Fatalf("loadsB = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := atomic.LoadInt32(&loadsA); got != 2 {
|
||||||
|
t.Fatalf("loadsA = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -19,6 +20,7 @@ const (
|
|||||||
indexFilename = "metadata.json"
|
indexFilename = "metadata.json"
|
||||||
filesDirName = "scratches"
|
filesDirName = "scratches"
|
||||||
idBytes = 12
|
idBytes = 12
|
||||||
|
maxCreateIDAttempts = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("scratch not found")
|
var ErrNotFound = errors.New("scratch not found")
|
||||||
@@ -29,6 +31,7 @@ type Metadata struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
|
OriginalName string `json:"original_name,omitempty"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +39,11 @@ type FilesystemStore struct {
|
|||||||
dataDir string
|
dataDir string
|
||||||
filesDir string
|
filesDir string
|
||||||
index string
|
index string
|
||||||
|
idGenerator func() (string, error)
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
metadata map[string]Metadata
|
metadata map[string]Metadata
|
||||||
|
reservedIDs map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFilesystemStore(dataDir string) (*FilesystemStore, error) {
|
func NewFilesystemStore(dataDir string) (*FilesystemStore, error) {
|
||||||
@@ -51,7 +56,9 @@ func NewFilesystemStore(dataDir string) (*FilesystemStore, error) {
|
|||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
filesDir: filesDir,
|
filesDir: filesDir,
|
||||||
index: filepath.Join(dataDir, indexFilename),
|
index: filepath.Join(dataDir, indexFilename),
|
||||||
|
idGenerator: generateID,
|
||||||
metadata: map[string]Metadata{},
|
metadata: map[string]Metadata{},
|
||||||
|
reservedIDs: map[string]struct{}{},
|
||||||
}
|
}
|
||||||
if err := store.loadIndex(); err != nil {
|
if err := store.loadIndex(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -63,13 +70,49 @@ func NewFilesystemStore(dataDir string) (*FilesystemStore, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) Create(ctx context.Context, body io.Reader, contentType string, ttl time.Duration) (Metadata, error) {
|
func (s *FilesystemStore) Create(ctx context.Context, body io.Reader, contentType string, ttl time.Duration) (Metadata, error) {
|
||||||
id, err := generateID()
|
return s.CreateWithOriginalName(ctx, body, contentType, "", ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FilesystemStore) CreateWithOriginalName(ctx context.Context, body io.Reader, contentType string, originalName string, ttl time.Duration) (Metadata, error) {
|
||||||
|
for attempt := 0; attempt < maxCreateIDAttempts; attempt++ {
|
||||||
|
id, err := s.idGenerator()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Metadata{}, fmt.Errorf("generate id: %w", err)
|
return Metadata{}, fmt.Errorf("generate id: %w", err)
|
||||||
}
|
}
|
||||||
|
finalPath := filepath.Join(s.filesDir, id)
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
_, exists := s.metadata[id]
|
||||||
|
_, reserved := s.reservedIDs[id]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if exists || reserved {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(finalPath); err == nil {
|
||||||
|
continue
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return Metadata{}, fmt.Errorf("stat candidate scratch path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if _, exists := s.metadata[id]; exists {
|
||||||
|
s.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, reserved := s.reservedIDs[id]; reserved {
|
||||||
|
s.mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Reserve the ID before consuming the request body to avoid
|
||||||
|
// concurrent writers choosing the same ID.
|
||||||
|
s.reservedIDs[id] = struct{}{}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
tempFile, err := os.CreateTemp(s.filesDir, id+".tmp-*")
|
tempFile, err := os.CreateTemp(s.filesDir, id+".tmp-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.reservedIDs, id)
|
||||||
|
s.mu.Unlock()
|
||||||
return Metadata{}, fmt.Errorf("create temp file: %w", err)
|
return Metadata{}, fmt.Errorf("create temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,20 +122,31 @@ func (s *FilesystemStore) Create(ctx context.Context, body io.Reader, contentTyp
|
|||||||
closeErr := tempFile.Close()
|
closeErr := tempFile.Close()
|
||||||
if copyErr != nil {
|
if copyErr != nil {
|
||||||
_ = os.Remove(tempFile.Name())
|
_ = os.Remove(tempFile.Name())
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.reservedIDs, id)
|
||||||
|
s.mu.Unlock()
|
||||||
return Metadata{}, fmt.Errorf("write scratch content: %w", copyErr)
|
return Metadata{}, fmt.Errorf("write scratch content: %w", copyErr)
|
||||||
}
|
}
|
||||||
if gzipCloseErr != nil {
|
if gzipCloseErr != nil {
|
||||||
_ = os.Remove(tempFile.Name())
|
_ = os.Remove(tempFile.Name())
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.reservedIDs, id)
|
||||||
|
s.mu.Unlock()
|
||||||
return Metadata{}, fmt.Errorf("close gzip writer: %w", gzipCloseErr)
|
return Metadata{}, fmt.Errorf("close gzip writer: %w", gzipCloseErr)
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
_ = os.Remove(tempFile.Name())
|
_ = os.Remove(tempFile.Name())
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.reservedIDs, id)
|
||||||
|
s.mu.Unlock()
|
||||||
return Metadata{}, fmt.Errorf("close temp file: %w", closeErr)
|
return Metadata{}, fmt.Errorf("close temp file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalPath := filepath.Join(s.filesDir, id)
|
|
||||||
if err := os.Rename(tempFile.Name(), finalPath); err != nil {
|
if err := os.Rename(tempFile.Name(), finalPath); err != nil {
|
||||||
_ = os.Remove(tempFile.Name())
|
_ = os.Remove(tempFile.Name())
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.reservedIDs, id)
|
||||||
|
s.mu.Unlock()
|
||||||
return Metadata{}, fmt.Errorf("atomically move scratch file: %w", err)
|
return Metadata{}, fmt.Errorf("atomically move scratch file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,10 +157,12 @@ func (s *FilesystemStore) Create(ctx context.Context, body io.Reader, contentTyp
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: now.Add(ttl),
|
ExpiresAt: now.Add(ttl),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
|
OriginalName: normalizeOriginalName(originalName),
|
||||||
Size: size,
|
Size: size,
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
delete(s.reservedIDs, id)
|
||||||
s.metadata[id] = meta
|
s.metadata[id] = meta
|
||||||
if err := s.persistLocked(); err != nil {
|
if err := s.persistLocked(); err != nil {
|
||||||
delete(s.metadata, id)
|
delete(s.metadata, id)
|
||||||
@@ -118,6 +174,22 @@ func (s *FilesystemStore) Create(ctx context.Context, body io.Reader, contentTyp
|
|||||||
return meta, nil
|
return meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Metadata{}, fmt.Errorf("failed to allocate unique scratch id after %d attempts", maxCreateIDAttempts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOriginalName(originalName string) string {
|
||||||
|
name := strings.TrimSpace(originalName)
|
||||||
|
if name == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name = filepath.Base(name)
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "." || name == string(filepath.Separator) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
func (s *FilesystemStore) Get(id string) (Metadata, error) {
|
func (s *FilesystemStore) Get(id string) (Metadata, error) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
meta, ok := s.metadata[id]
|
meta, ok := s.metadata[id]
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -51,6 +54,226 @@ func TestCreateStoresGzipAndOpenIsTransparent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateWithOriginalNameNormalizesFilename(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", " ../../demo.txt ", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWithOriginalName() error = %v", err)
|
||||||
|
}
|
||||||
|
if meta.OriginalName != "demo.txt" {
|
||||||
|
t.Fatalf("meta.OriginalName = %q, want %q", meta.OriginalName, "demo.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded, err := NewFilesystemStore(store.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() reload error = %v", err)
|
||||||
|
}
|
||||||
|
got, err := reloaded.Get(meta.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.OriginalName != "demo.txt" {
|
||||||
|
t.Fatalf("persisted OriginalName = %q, want %q", got.OriginalName, "demo.txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateWithOriginalNameRetriesOnIDCollision(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingID := "aaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
existingPath := filepath.Join(store.filesDir, existingID)
|
||||||
|
if err := os.WriteFile(existingPath, []byte{0x1f, 0x8b, 0x08, 0x00}, 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(existing collision path) error = %v", err)
|
||||||
|
}
|
||||||
|
store.mu.Lock()
|
||||||
|
store.metadata[existingID] = Metadata{
|
||||||
|
ID: existingID,
|
||||||
|
FilePath: existingPath,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Size: 4,
|
||||||
|
}
|
||||||
|
store.mu.Unlock()
|
||||||
|
|
||||||
|
nextID := "bbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
calls := 0
|
||||||
|
store.idGenerator = func() (string, error) {
|
||||||
|
calls++
|
||||||
|
if calls == 1 {
|
||||||
|
return existingID, nil
|
||||||
|
}
|
||||||
|
return nextID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", "hello.txt", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWithOriginalName() error = %v", err)
|
||||||
|
}
|
||||||
|
if meta.ID != nextID {
|
||||||
|
t.Fatalf("meta.ID = %q, want %q", meta.ID, nextID)
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("idGenerator calls = %d, want 2", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateWithOriginalNameFailsAfterMaxIDRetries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingID := "cccccccccccccccccccccccc"
|
||||||
|
existingPath := filepath.Join(store.filesDir, existingID)
|
||||||
|
store.mu.Lock()
|
||||||
|
store.metadata[existingID] = Metadata{
|
||||||
|
ID: existingID,
|
||||||
|
FilePath: existingPath,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||||
|
ContentType: "text/plain",
|
||||||
|
}
|
||||||
|
store.mu.Unlock()
|
||||||
|
|
||||||
|
store.idGenerator = func() (string, error) {
|
||||||
|
return existingID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = store.CreateWithOriginalName(context.Background(), bytes.NewReader([]byte("hello")), "text/plain", "hello.txt", time.Hour)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("CreateWithOriginalName() error = nil, want retry exhaustion error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to allocate unique scratch id") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateWithOriginalNameRetriesWhenIDIsReservedInFlight(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collideID = "abababababababababababab"
|
||||||
|
const uniqueID = "cdcdcdcdcdcdcdcdcdcdcdcd"
|
||||||
|
|
||||||
|
var idMu sync.Mutex
|
||||||
|
idCalls := 0
|
||||||
|
store.idGenerator = func() (string, error) {
|
||||||
|
idMu.Lock()
|
||||||
|
defer idMu.Unlock()
|
||||||
|
idCalls++
|
||||||
|
if idCalls <= 2 {
|
||||||
|
return collideID, nil
|
||||||
|
}
|
||||||
|
return uniqueID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
firstMetaCh := make(chan Metadata, 1)
|
||||||
|
firstErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
meta, createErr := store.CreateWithOriginalName(
|
||||||
|
context.Background(),
|
||||||
|
&blockingReader{release: release, payload: []byte("first upload")},
|
||||||
|
"text/plain",
|
||||||
|
"first.txt",
|
||||||
|
time.Hour,
|
||||||
|
)
|
||||||
|
firstMetaCh <- meta
|
||||||
|
firstErrCh <- createErr
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
store.mu.RLock()
|
||||||
|
_, reserved := store.reservedIDs[collideID]
|
||||||
|
store.mu.RUnlock()
|
||||||
|
if reserved {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("timed out waiting for first upload reservation")
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondMeta, err := store.CreateWithOriginalName(
|
||||||
|
context.Background(),
|
||||||
|
strings.NewReader("second upload"),
|
||||||
|
"text/plain",
|
||||||
|
"second.txt",
|
||||||
|
time.Hour,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second CreateWithOriginalName() error = %v", err)
|
||||||
|
}
|
||||||
|
if secondMeta.ID != uniqueID {
|
||||||
|
t.Fatalf("second meta.ID = %q, want %q after collision retry", secondMeta.ID, uniqueID)
|
||||||
|
}
|
||||||
|
if secondMeta.ID == collideID {
|
||||||
|
t.Fatalf("second create reused in-flight reserved ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
firstMeta := <-firstMetaCh
|
||||||
|
if err := <-firstErrCh; err != nil {
|
||||||
|
t.Fatalf("first CreateWithOriginalName() error = %v", err)
|
||||||
|
}
|
||||||
|
if firstMeta.ID != collideID {
|
||||||
|
t.Fatalf("first meta.ID = %q, want %q", firstMeta.ID, collideID)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstReader, _, err := store.Open(firstMeta.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open(first) error = %v", err)
|
||||||
|
}
|
||||||
|
defer firstReader.Close()
|
||||||
|
firstBody, err := io.ReadAll(firstReader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll(first) error = %v", err)
|
||||||
|
}
|
||||||
|
if string(firstBody) != "first upload" {
|
||||||
|
t.Fatalf("first content = %q, want %q", string(firstBody), "first upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
secondReader, _, err := store.Open(secondMeta.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open(second) error = %v", err)
|
||||||
|
}
|
||||||
|
defer secondReader.Close()
|
||||||
|
secondBody, err := io.ReadAll(secondReader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll(second) error = %v", err)
|
||||||
|
}
|
||||||
|
if string(secondBody) != "second upload" {
|
||||||
|
t.Fatalf("second content = %q, want %q", string(secondBody), "second upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
idMu.Lock()
|
||||||
|
totalCalls := idCalls
|
||||||
|
idMu.Unlock()
|
||||||
|
if totalCalls < 3 {
|
||||||
|
t.Fatalf("idGenerator call count = %d, want at least 3", totalCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetDeleteAndNotFound(t *testing.T) {
|
func TestGetDeleteAndNotFound(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -400,3 +623,166 @@ func TestNewFilesystemStoreFailsWhenDataDirIsFile(t *testing.T) {
|
|||||||
t.Fatalf("NewFilesystemStore() error = nil, want mkdir failure")
|
t.Fatalf("NewFilesystemStore() error = nil, want mkdir failure")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type blockingReader struct {
|
||||||
|
release <-chan struct{}
|
||||||
|
payload []byte
|
||||||
|
sent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *blockingReader) Read(p []byte) (int, error) {
|
||||||
|
if r.sent {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
<-r.release
|
||||||
|
r.sent = true
|
||||||
|
return copy(p, r.payload), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateReservationIsNotVisibleToCleanupOrReads(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservedID = "ffffffffffffffffffffffff"
|
||||||
|
store.idGenerator = func() (string, error) {
|
||||||
|
return reservedID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
release := make(chan struct{})
|
||||||
|
createErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, createErr := store.CreateWithOriginalName(
|
||||||
|
context.Background(),
|
||||||
|
&blockingReader{release: release, payload: []byte("in-flight")},
|
||||||
|
"text/plain",
|
||||||
|
"",
|
||||||
|
time.Hour,
|
||||||
|
)
|
||||||
|
createErrCh <- createErr
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
store.mu.RLock()
|
||||||
|
_, reserved := store.reservedIDs[reservedID]
|
||||||
|
_, visible := store.metadata[reservedID]
|
||||||
|
store.mu.RUnlock()
|
||||||
|
if reserved {
|
||||||
|
if visible {
|
||||||
|
t.Fatalf("in-flight ID should not appear in metadata")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("timed out waiting for ID reservation")
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Get(reservedID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("Get() during in-flight create error = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := store.DeleteExpired(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DeleteExpired() error = %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 0 {
|
||||||
|
t.Fatalf("DeleteExpired() = %d, want 0 for in-flight create", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
if err := <-createErrCh; err != nil {
|
||||||
|
t.Fatalf("CreateWithOriginalName() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, err := store.Open(reservedID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open() error = %v", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "in-flight" {
|
||||||
|
t.Fatalf("Open() content = %q, want %q", string(body), "in-flight")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentCreateWithOriginalNameProducesUniqueReadableEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
store, err := NewFilesystemStore(filepath.Join(t.TempDir(), "data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const writers = 64
|
||||||
|
errCh := make(chan error, writers)
|
||||||
|
idsCh := make(chan string, writers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < writers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
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),
|
||||||
|
time.Hour,
|
||||||
|
)
|
||||||
|
if createErr != nil {
|
||||||
|
errCh <- fmt.Errorf("CreateWithOriginalName() error: %w", createErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, _, openErr := store.Open(meta.ID)
|
||||||
|
if openErr != nil {
|
||||||
|
errCh <- fmt.Errorf("Open() error: %w", openErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
got, readErr := io.ReadAll(reader)
|
||||||
|
if readErr != nil {
|
||||||
|
errCh <- fmt.Errorf("ReadAll() error: %w", readErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if string(got) != payload {
|
||||||
|
errCh <- fmt.Errorf("content mismatch = %q, want %q", string(got), payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idsCh <- meta.ID
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
close(idsCh)
|
||||||
|
for err := range errCh {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, writers)
|
||||||
|
for id := range idsCh {
|
||||||
|
if _, exists := seen[id]; exists {
|
||||||
|
t.Errorf("duplicate id detected: %s", id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(seen) != writers {
|
||||||
|
t.Fatalf("unique IDs = %d, want %d", len(seen), writers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package webassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
|
func StaticFS() fs.FS {
|
||||||
|
return mustSub("static")
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustSub(dir string) fs.FS {
|
||||||
|
sub, err := fs.Sub(embeddedFiles, dir)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: Inter, system-ui, sans-serif;
|
|
||||||
background: #10141a;
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 2rem auto;
|
|
||||||
padding: 0 1rem 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
padding: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: #3b82f6;
|
|
||||||
border: 0;
|
|
||||||
color: white;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
margin-top: 1rem;
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: #0b1220;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1rem;
|
|
||||||
overflow: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
body{color:#e7ebf3;background:#0b0e14;margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}.container.svelte-1n46o8q{max-width:960px;margin:0 auto;padding:2rem 1.25rem 3rem}.site-header.svelte-1n46o8q,.site-footer.svelte-1n46o8q,.panel.svelte-1n46o8q{background:#101726;border:1px solid #2a3243;border-radius:10px}.site-header.svelte-1n46o8q,.site-footer.svelte-1n46o8q{padding:1rem 1.1rem}.site-header.svelte-1n46o8q{justify-content:space-between;align-items:center;gap:1rem;display:flex}.brand-row.svelte-1n46o8q{flex-direction:column;gap:.1rem;display:flex}.brand.svelte-1n46o8q{color:#f5f7fc;font-size:1.3rem;font-weight:700;text-decoration:none}.tagline.svelte-1n46o8q{color:#b1b7c7;font-size:.95rem}.site-nav.svelte-1n46o8q{gap:.85rem;display:flex}.panel.svelte-1n46o8q{margin-top:1rem;padding:1.2rem}.site-footer.svelte-1n46o8q{color:#b1b7c7;margin-top:1rem}.helper.svelte-1n46o8q{color:#b1b7c7;margin-top:.5rem}.link-button.svelte-1n46o8q{color:#f2f5fb;background:#213f75;border:1px solid #2f67c8;border-radius:8px;justify-content:center;align-items:center;padding:.45rem .8rem;font-weight:600;text-decoration:none;display:inline-flex}.nav-button.svelte-1n46o8q{background:#162640;border-color:#355487;font-weight:500}.warning.svelte-1n46o8q{color:#f4c16e}.mode-picker.svelte-1n46o8q,.input-panel.svelte-1n46o8q,#error.svelte-1n46o8q{margin-top:1rem}.mode-picker.svelte-1n46o8q{border:1px solid #2a3243;border-radius:8px;padding:.75rem}.mode-buttons.svelte-1n46o8q{gap:.5rem;display:flex}.mode-button.svelte-1n46o8q{color:#e7ebf3;cursor:pointer;background:#121826;border:1px solid #2a3243;border-radius:8px;padding:.4rem .8rem}.mode-button.is-active.svelte-1n46o8q{background:#2b4b89;border-color:#4a75c4}textarea.svelte-1n46o8q,input[type=file].svelte-1n46o8q{color:#e7ebf3;background:#121826;border:1px solid #2a3243;border-radius:8px;width:100%;margin-top:.5rem;padding:.7rem}button[type=submit].svelte-1n46o8q{color:#f2f5fb;cursor:pointer;background:#2b4b89;border:1px solid #2f67c8;border-radius:8px;margin-top:1rem;padding:.6rem 1rem}button[disabled].svelte-1n46o8q{opacity:.65;cursor:not-allowed}pre.svelte-1n46o8q{white-space:pre-wrap;background:#101726;border:1px solid #2a3243;border-radius:8px;max-width:100%;padding:1rem;overflow:auto}.scratch-image.svelte-1n46o8q{background:#0b0e14;border:1px solid #2a3243;border-radius:8px;max-width:100%;max-height:70vh;display:block}.error.svelte-1n46o8q{color:#ff9b9b}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Scratchbox</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-Bu5AaCmv.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-C05A_xhw.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Scratchbox</title>
|
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="container">
|
|
||||||
<h1>Scratchbox</h1>
|
|
||||||
<p>Create an unauthenticated scratch. It expires automatically.</p>
|
|
||||||
|
|
||||||
<form id="scratch-form">
|
|
||||||
<label for="content">Text content</label>
|
|
||||||
<textarea id="content" name="content" rows="14" placeholder="Paste text here"></textarea>
|
|
||||||
<button type="submit">Create scratch</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<section id="result" class="hidden">
|
|
||||||
<h2>Created</h2>
|
|
||||||
<p><strong>View:</strong> <a id="view-link" href="#" target="_blank" rel="noreferrer"></a></p>
|
|
||||||
<p><strong>Raw:</strong> <a id="raw-link" href="#" target="_blank" rel="noreferrer"></a></p>
|
|
||||||
<p id="expire-text"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="error" class="error hidden"></section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const form = document.getElementById("scratch-form");
|
|
||||||
const result = document.getElementById("result");
|
|
||||||
const error = document.getElementById("error");
|
|
||||||
const viewLink = document.getElementById("view-link");
|
|
||||||
const rawLink = document.getElementById("raw-link");
|
|
||||||
const expireText = document.getElementById("expire-text");
|
|
||||||
|
|
||||||
form.addEventListener("submit", async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
result.classList.add("hidden");
|
|
||||||
error.classList.add("hidden");
|
|
||||||
|
|
||||||
const content = document.getElementById("content").value;
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/scratch", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
||||||
body: content
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const message = await response.text();
|
|
||||||
throw new Error(message || `request failed (${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await response.json();
|
|
||||||
viewLink.href = payload.view_url;
|
|
||||||
viewLink.textContent = payload.view_url;
|
|
||||||
rawLink.href = payload.raw_url;
|
|
||||||
rawLink.textContent = payload.raw_url;
|
|
||||||
expireText.textContent = `Expires at: ${payload.expires_at}`;
|
|
||||||
result.classList.remove("hidden");
|
|
||||||
} catch (err) {
|
|
||||||
error.textContent = err.message;
|
|
||||||
error.classList.remove("hidden");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>Scratch {{ .ID }}</title>
|
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="container">
|
|
||||||
<h1>Scratch {{ .ID }}</h1>
|
|
||||||
<p>Content type: <code>{{ .ContentType }}</code></p>
|
|
||||||
<p>Expires at: {{ .ExpiresAt }}</p>
|
|
||||||
<p><a href="{{ .RawURL }}">Open raw</a></p>
|
|
||||||
|
|
||||||
{{ if .Binary }}
|
|
||||||
<p>This scratch looks binary and is not rendered in-page. Use the raw link.</p>
|
|
||||||
{{ else }}
|
|
||||||
<pre>{{ .Content }}</pre>
|
|
||||||
{{ if .Truncated }}
|
|
||||||
<p>Preview truncated to 2MB. Open raw for full content.</p>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Scratchbox</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1173
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "scratchbox-web-ui",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"svelte": "^5.56.0",
|
||||||
|
"vite": "^8.0.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,641 @@
|
|||||||
|
<script>
|
||||||
|
import { onDestroy, onMount } from "svelte";
|
||||||
|
|
||||||
|
let mode = $state("text");
|
||||||
|
let content = $state("");
|
||||||
|
let selectedFile = $state(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let errorMessage = $state("");
|
||||||
|
|
||||||
|
let maxUploadSizeBytes = $state(0);
|
||||||
|
let uploadAllowed = $state(true);
|
||||||
|
let configError = $state("");
|
||||||
|
|
||||||
|
let routeMode = $state("home");
|
||||||
|
let scratchID = $state("");
|
||||||
|
|
||||||
|
let viewLoading = $state(false);
|
||||||
|
let viewError = $state("");
|
||||||
|
let viewMeta = $state(null);
|
||||||
|
let viewContent = $state("");
|
||||||
|
let viewIsBinary = $state(false);
|
||||||
|
let viewIsImage = $state(false);
|
||||||
|
let refreshTriggered = $state(false);
|
||||||
|
let nowMs = $state(Date.now());
|
||||||
|
|
||||||
|
let nowTicker = null;
|
||||||
|
|
||||||
|
const maxUploadSizeDisplay = $derived(formatBytes(maxUploadSizeBytes, true));
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
nowTicker = window.setInterval(() => {
|
||||||
|
nowMs = Date.now();
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
detectRoute();
|
||||||
|
await loadUIConfig();
|
||||||
|
if (routeMode === "view") {
|
||||||
|
await loadScratchView();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (nowTicker !== null) {
|
||||||
|
window.clearInterval(nowTicker);
|
||||||
|
nowTicker = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function detectRoute() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path === "/" || path === "") {
|
||||||
|
routeMode = "home";
|
||||||
|
scratchID = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/u" || path === "/u/") {
|
||||||
|
routeMode = "upload";
|
||||||
|
scratchID = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.startsWith("/s/")) {
|
||||||
|
routeMode = "notfound";
|
||||||
|
scratchID = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawID = path.slice("/s/".length).trim();
|
||||||
|
if (rawID.length === 0) {
|
||||||
|
routeMode = "notfound";
|
||||||
|
scratchID = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
routeMode = "view";
|
||||||
|
scratchID = decodeURIComponent(rawID);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUIConfig() {
|
||||||
|
configError = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/config");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`failed to load UI config (${response.status})`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const max = Number.parseInt(String(data.max_upload_size_bytes ?? "0"), 10);
|
||||||
|
maxUploadSizeBytes = Number.isFinite(max) ? max : 0;
|
||||||
|
uploadAllowed = Boolean(data.upload_allowed);
|
||||||
|
} catch (err) {
|
||||||
|
configError = err instanceof Error ? err.message : String(err);
|
||||||
|
maxUploadSizeBytes = 0;
|
||||||
|
uploadAllowed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScratchView() {
|
||||||
|
if (!scratchID) {
|
||||||
|
viewError = "Scratch id is missing.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewLoading = true;
|
||||||
|
viewError = "";
|
||||||
|
viewMeta = null;
|
||||||
|
viewContent = "";
|
||||||
|
viewIsBinary = false;
|
||||||
|
viewIsImage = false;
|
||||||
|
refreshTriggered = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const metaResponse = await fetch(`/api/scratch/${encodeURIComponent(scratchID)}`);
|
||||||
|
if (!metaResponse.ok) {
|
||||||
|
if (metaResponse.status === 404 || metaResponse.status === 410) {
|
||||||
|
routeMode = "expired";
|
||||||
|
viewError = "This scratch is no longer available.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = await metaResponse.text();
|
||||||
|
throw new Error(msg || `failed to load scratch metadata (${metaResponse.status})`);
|
||||||
|
}
|
||||||
|
viewMeta = await metaResponse.json();
|
||||||
|
|
||||||
|
const rawResponse = await fetch(`/api/raw/${encodeURIComponent(scratchID)}`);
|
||||||
|
if (!rawResponse.ok) {
|
||||||
|
const msg = await rawResponse.text();
|
||||||
|
throw new Error(msg || `failed to load scratch content (${rawResponse.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = rawResponse.headers.get("content-type") ?? "";
|
||||||
|
if (isLikelyTextContentType(contentType)) {
|
||||||
|
viewContent = await rawResponse.text();
|
||||||
|
viewIsBinary = false;
|
||||||
|
viewIsImage = false;
|
||||||
|
} else if (isBrowserImageContentType(contentType)) {
|
||||||
|
viewIsImage = true;
|
||||||
|
viewIsBinary = false;
|
||||||
|
} else {
|
||||||
|
viewIsImage = false;
|
||||||
|
viewIsBinary = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
viewError = err instanceof Error ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
viewLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingZeros(value) {
|
||||||
|
return value.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes, binary) {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||||
|
return "0 bytes";
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = binary ? 1024 : 1000;
|
||||||
|
const units = binary ? ["KiB", "MiB", "GiB"] : ["KB", "MB", "GB"];
|
||||||
|
if (bytes < base) {
|
||||||
|
return `${bytes} bytes`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = bytes;
|
||||||
|
let unitIndex = -1;
|
||||||
|
while (value >= base && unitIndex < units.length - 1) {
|
||||||
|
value /= base;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rounded = value >= 10 ? value.toFixed(1) : value.toFixed(2);
|
||||||
|
return `${trimTrailingZeros(rounded)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyTextContentType(contentType) {
|
||||||
|
const normalized = String(contentType).toLowerCase();
|
||||||
|
if (normalized.startsWith("text/")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
normalized.startsWith("application/json") ||
|
||||||
|
normalized.startsWith("application/xml") ||
|
||||||
|
normalized.startsWith("application/javascript") ||
|
||||||
|
normalized.startsWith("application/x-www-form-urlencoded")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBrowserImageContentType(contentType) {
|
||||||
|
return String(contentType).toLowerCase().startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (refreshTriggered || routeMode !== "view" || !viewMeta?.expires_at) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiry = Date.parse(String(viewMeta.expires_at));
|
||||||
|
if (!Number.isFinite(expiry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nowMs >= expiry+10_000) {
|
||||||
|
refreshTriggered = true;
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function expiresInText(expiresAt, now) {
|
||||||
|
const expiry = Date.parse(String(expiresAt ?? ""));
|
||||||
|
if (!Number.isFinite(expiry)) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
const deltaMs = Math.max(0, expiry - now);
|
||||||
|
if (deltaMs === 0) {
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSeconds = Math.floor(deltaMs / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchMode(nextMode) {
|
||||||
|
if (loading || nextMode === mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mode = nextMode;
|
||||||
|
errorMessage = "";
|
||||||
|
if (mode === "text") {
|
||||||
|
selectedFile = null;
|
||||||
|
} else {
|
||||||
|
content = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(event) {
|
||||||
|
selectedFile = event.currentTarget.files?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!uploadAllowed) {
|
||||||
|
errorMessage = "Uploads are not available from your network location.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorMessage = "";
|
||||||
|
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
if (mode === "text") {
|
||||||
|
if (!trimmedContent) {
|
||||||
|
errorMessage = "Enter text content before creating a scratch.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!selectedFile) {
|
||||||
|
errorMessage = "Choose a file before creating a scratch.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (maxUploadSizeBytes > 0 && selectedFile.size > maxUploadSizeBytes) {
|
||||||
|
errorMessage = `Selected file is too large (${formatBytes(selectedFile.size, true)}). Max upload size is ${maxUploadSizeDisplay}.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (mode === "file") {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", selectedFile);
|
||||||
|
response = await fetch("/api/scratch", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch("/api/scratch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
|
body: trimmedContent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await response.text();
|
||||||
|
throw new Error(message || `request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await response.json();
|
||||||
|
const viewURL = typeof created?.view_url === "string" ? created.view_url : "";
|
||||||
|
if (!viewURL) {
|
||||||
|
throw new Error("scratch created but view URL is missing");
|
||||||
|
}
|
||||||
|
window.location.assign(viewURL);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (mode === "file" && err instanceof TypeError) {
|
||||||
|
errorMessage = `Upload failed before the server returned a response. Max upload size is ${maxUploadSizeDisplay}.`;
|
||||||
|
} else {
|
||||||
|
errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<header class="site-header">
|
||||||
|
<div class="brand-row">
|
||||||
|
<a class="brand" href="/">Scratchbox</a>
|
||||||
|
<span class="tagline">Quick unauthenticated scratch uploads</span>
|
||||||
|
</div>
|
||||||
|
<nav class="site-nav" aria-label="Primary">
|
||||||
|
<a class="link-button nav-button" href="/">Home</a>
|
||||||
|
{#if uploadAllowed}
|
||||||
|
<a class="link-button nav-button" href="/u">Upload</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if routeMode === "home"}
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Share scratch files and text quickly</h1>
|
||||||
|
<p>
|
||||||
|
Scratchbox is a minimal temporary upload service. Every upload creates one scratch with an expiration
|
||||||
|
timestamp. It supports plain text and single file uploads, plus direct raw retrieval for automation.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
No accounts, no sessions, no friction. Create a scratch, grab the URL, and share it.
|
||||||
|
</p>
|
||||||
|
{#if !uploadAllowed}
|
||||||
|
<p class="helper">Uploads are currently unavailable from your network location.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if routeMode === "view"}
|
||||||
|
<section class="panel">
|
||||||
|
{#if viewLoading}
|
||||||
|
<p>Loading scratch...</p>
|
||||||
|
{:else if viewError}
|
||||||
|
<section id="error" class="error">{viewError}</section>
|
||||||
|
{:else if viewMeta}
|
||||||
|
{#if viewIsImage}
|
||||||
|
<img
|
||||||
|
class="scratch-image"
|
||||||
|
src={viewMeta.raw_url}
|
||||||
|
alt={`Scratch ${scratchID}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{:else if viewIsBinary}
|
||||||
|
<p>This scratch looks binary and is not rendered in-page. Use the raw link.</p>
|
||||||
|
{:else}
|
||||||
|
<pre>{viewContent}</pre>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p><strong>Expires in:</strong> {expiresInText(viewMeta.expires_at, nowMs)}</p>
|
||||||
|
<p><a class="link-button" href={viewMeta.raw_url} target="_blank" rel="noreferrer">Download</a></p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if routeMode === "upload"}
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Upload</h1>
|
||||||
|
<p>Create an unauthenticated scratch. It expires automatically.</p>
|
||||||
|
<p class="helper">Max upload size: {maxUploadSizeDisplay}</p>
|
||||||
|
{#if configError}
|
||||||
|
<p class="helper warning">Could not load UI config: {configError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if !uploadAllowed}
|
||||||
|
<section id="error" class="error">
|
||||||
|
Uploads are disabled for your current IP based on server allowlist settings.
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<form id="scratch-form" onsubmit={onSubmit}>
|
||||||
|
<fieldset class="mode-picker">
|
||||||
|
<legend>Upload mode</legend>
|
||||||
|
<div class="mode-buttons" role="tablist" aria-label="Upload mode">
|
||||||
|
<button
|
||||||
|
id="mode-text"
|
||||||
|
type="button"
|
||||||
|
class:mode-button={true}
|
||||||
|
class:is-active={mode === "text"}
|
||||||
|
aria-pressed={mode === "text"}
|
||||||
|
onclick={() => switchMode("text")}
|
||||||
|
>
|
||||||
|
Text
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="mode-file"
|
||||||
|
type="button"
|
||||||
|
class:mode-button={true}
|
||||||
|
class:is-active={mode === "file"}
|
||||||
|
aria-pressed={mode === "file"}
|
||||||
|
onclick={() => switchMode("file")}
|
||||||
|
>
|
||||||
|
File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if mode === "text"}
|
||||||
|
<section id="text-panel" class="input-panel">
|
||||||
|
<label for="content">Text content</label>
|
||||||
|
<textarea
|
||||||
|
id="content"
|
||||||
|
name="content"
|
||||||
|
rows="14"
|
||||||
|
placeholder="Paste text here"
|
||||||
|
bind:value={content}
|
||||||
|
disabled={loading}
|
||||||
|
></textarea>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<section id="file-panel" class="input-panel">
|
||||||
|
<label for="file">File upload</label>
|
||||||
|
<input
|
||||||
|
id="file"
|
||||||
|
name="file"
|
||||||
|
type="file"
|
||||||
|
onchange={onFileChange}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<p class="helper">Upload one file per scratch.</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{#if loading}Creating...{:else}Create scratch{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<section id="error" class="error">{errorMessage}</section>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{:else if routeMode === "expired"}
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Scratch expired</h1>
|
||||||
|
<p>{viewError || "The scratch you requested is no longer available because its retention time elapsed."}</p>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<section class="panel">
|
||||||
|
<h1>Page not found</h1>
|
||||||
|
<p>The requested path does not exist.</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<p>Scratchbox - simple temporary uploads</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: #0b0e14;
|
||||||
|
color: #e7ebf3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.25rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header,
|
||||||
|
.site-footer,
|
||||||
|
.panel {
|
||||||
|
border: 1px solid #2a3243;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #101726;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header,
|
||||||
|
.site-footer {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #f5f7fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #b1b7c7;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #b1b7c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.helper {
|
||||||
|
color: #b1b7c7;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
border: 1px solid #2f67c8;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #213f75;
|
||||||
|
color: #f2f5fb;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
background: #162640;
|
||||||
|
border-color: #355487;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: #f4c16e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-picker,
|
||||||
|
.input-panel,
|
||||||
|
#error {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-picker {
|
||||||
|
border: 1px solid #2a3243;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-button {
|
||||||
|
border: 1px solid #2a3243;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #121826;
|
||||||
|
color: #e7ebf3;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-button.is-active {
|
||||||
|
background: #2b4b89;
|
||||||
|
border-color: #4a75c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea,
|
||||||
|
input[type="file"] {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
background: #121826;
|
||||||
|
color: #e7ebf3;
|
||||||
|
border: 1px solid #2a3243;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="submit"] {
|
||||||
|
margin-top: 1rem;
|
||||||
|
border: 1px solid #2f67c8;
|
||||||
|
background: #2b4b89;
|
||||||
|
color: #f2f5fb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[disabled] {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: #101726;
|
||||||
|
border: 1px solid #2a3243;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scratch-image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
border: 1px solid #2a3243;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0b0e14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff9b9b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { mount } from "svelte";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
|
||||||
|
const target = document.getElementById("app");
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
mount(App, {
|
||||||
|
target
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
build: {
|
||||||
|
outDir: "../static",
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user