diff --git a/.gitignore b/.gitignore index 045c20d..3beb6f8 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json todo.md .dex-plans/ .dex/ + +# Bench load-generator binary built for the container runs +bench/.bin/ diff --git a/Dockerfile b/Dockerfile index 18fef02..4bd2e2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,8 +11,15 @@ COPY api ./api COPY sdk ./sdk RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/filegate ./cmd/filegate +# Distroless has no shell, so the runtime directories are staged in the build +# image and copied in with the right ownership. Without them a container that +# configures nothing cannot create its default mount and refuses to start. +RUN mkdir -p /stage/var/lib/filegate/data /stage/var/lib/filegate/index /stage/var/lib/filegate/config \ + && chown -R 65532:65532 /stage/var/lib/filegate + FROM gcr.io/distroless/static-debian12:nonroot WORKDIR /app +COPY --from=build --chown=65532:65532 /stage/var/lib/filegate /var/lib/filegate COPY --from=build /out/filegate /app/filegate EXPOSE 8080/tcp ENTRYPOINT ["/app/filegate"] diff --git a/Makefile b/Makefile index ca6229a..627daf2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ -.PHONY: test test-race test-short test-detector-linux test-detector-soak test-detector-chaos test-detector-btrfs-real test-detector-btrfs-real-docker test-versioning-btrfs-real-docker test-versioning-soak fuzz-smoke bench-go bench-http bench-compose check +.PHONY: docs-config test test-race test-short test-detector-linux test-detector-soak test-detector-chaos test-detector-btrfs-real test-detector-btrfs-real-docker test-versioning-btrfs-real-docker test-versioning-soak fuzz-smoke bench-go bench-http bench-compose bench-tree check + +docs-config: + go run ./cmd/filegate config schema --format markdown > docs-site/docs/en/reference/config.md test: go test ./... @@ -45,4 +48,7 @@ bench-http: bench-compose: ./bench/scripts/run-http-bench-compose.sh +bench-tree: + ./bench/scripts/run-tree-bench.sh + check: test test-race test-detector-linux bench-go diff --git a/README.md b/README.md index ccd896d..9847855 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,27 @@ curl -fsS -H 'Authorization: Bearer dev-token' \ ## Configuration -Filegate reads config from `--config`, `FILEGATE_CONFIG`, or default candidates such as `/etc/filegate/conf.yaml`. Environment variables use `FILEGATE_` plus the config path, for example `FILEGATE_SERVER_LISTEN`. +Filegate uses a versioned desired-state manifest for repository-managed configuration. Plan and apply use the same bearer-authenticated HTTP API locally and remotely: + +```yaml +# filegate.manifest.yaml +version: 1 +config: + server: + public_url: https://files.example.com + access_log_enabled: true + upload: + max_upload_bytes: 1073741824 +``` + +```bash +fg config plan -f filegate.manifest.yaml --host https://files.example.com --token-file /run/secrets/filegate-token +fg config apply -f filegate.manifest.yaml --host https://files.example.com --token-file /run/secrets/filegate-token +``` + +The manifest is a complete replacement: removing a key removes it from managed state. Runtime keys apply immediately; static keys are stored as desired state until restart. The admin Settings page is a read-only view of effective and desired configuration. + +Bootstrap settings and secrets still come from `--config`, `FILEGATE_CONFIG`, environment, or default candidates such as `/etc/filegate/conf.yaml`. Environment variables use `FILEGATE_` plus the config path, for example `FILEGATE_SERVER_LISTEN`. Use the config CLI for offline edits: @@ -128,13 +148,12 @@ sudo fg config mount add --config /etc/filegate/conf.yaml /srv/filegate/photos sudo fg config set --config /etc/filegate/conf.yaml \ --auth-bearer-token '' \ - --server-listen ':8080' \ - --server-public-url 'https://files.example.com' + --server-listen ':8080' ``` Mutating `fg config` commands require explicit `--config`, create a timestamped backup by default, validate the resulting YAML before replacing it, and print a restart reminder. They do not hot-reload a running daemon. -`fg serve` accepts the same config-value flags as one-shot runtime overrides: +`fg serve` accepts the same config-value flags as one-shot startup overrides: ```bash fg serve --config ./conf.yaml --server-listen ':9090' @@ -411,7 +430,7 @@ The default ring buffer retains 500 records. Set `activity.ring_buffer_size` to ## Limits - Single-node service; no replication. -- Config changes are offline; restart after editing config. +- Bootstrap config changes are offline. Manifest runtime keys apply immediately; static keys take effect after restart. - REST uses one bearer token. S3 supports multiple keys and per-key bucket allowlists. - REST has no request rate limiting. S3 supports per-key request limits. - `X-Forwarded-For` is trusted only from configured `server.trusted_proxies`. diff --git a/adapter/http/config.go b/adapter/http/config.go new file mode 100644 index 0000000..85a2219 --- /dev/null +++ b/adapter/http/config.go @@ -0,0 +1,73 @@ +package httpadapter + +import ( + "errors" + "net/http" + + apiv1 "github.com/valentinkolb/filegate/api/v1" + "github.com/valentinkolb/filegate/domain" + "github.com/valentinkolb/filegate/infra/activity" +) + +// ConfigService is the declarative configuration surface the router exposes. +type ConfigService interface { + Schema() []apiv1.ConfigKeySchema + Values() apiv1.ConfigValuesResponse + PlanManifest(values map[string]any) (apiv1.ConfigManifestPlanResponse, error) + ApplyManifest(values map[string]any, expectedRevision, actor string) (apiv1.ConfigManifestApplyResponse, error) +} + +type configHandlers struct { + svc ConfigService +} + +func (h configHandlers) handleSchema(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, apiv1.ConfigSchemaResponse{Keys: h.svc.Schema()}) +} + +func (h configHandlers) handleValues(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, h.svc.Values()) +} + +func (h configHandlers) handlePlan(w http.ResponseWriter, r *http.Request) { + var req apiv1.ConfigManifestPlanRequest + if !decodeStrict(w, r, &req) { + return + } + if req.Values == nil { + req.Values = map[string]any{} + } + plan, err := h.svc.PlanManifest(req.Values) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, plan) +} + +func (h configHandlers) handleApply(w http.ResponseWriter, r *http.Request) { + var req apiv1.ConfigManifestApplyRequest + if !decodeStrict(w, r, &req) { + return + } + if req.Values == nil { + req.Values = map[string]any{} + } + applied, err := h.svc.ApplyManifest(req.Values, req.ExpectedRevision, configActor(r)) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, domain.ErrConflict) { + status = http.StatusConflict + } + writeErr(w, status, err.Error()) + return + } + writeJSON(w, http.StatusOK, applied) +} + +func configActor(r *http.Request) string { + if label := activity.CleanActorLabel(r.Header.Get("X-Filegate-Actor")); label != "" { + return label + } + return "bearer-token" +} diff --git a/adapter/http/config_test.go b/adapter/http/config_test.go new file mode 100644 index 0000000..c932c61 --- /dev/null +++ b/adapter/http/config_test.go @@ -0,0 +1,99 @@ +package httpadapter + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + apiv1 "github.com/valentinkolb/filegate/api/v1" + "github.com/valentinkolb/filegate/domain" +) + +type configServiceStub struct { + planned map[string]any + applied map[string]any + expected string + actor string + applyErr error +} + +func (s *configServiceStub) Schema() []apiv1.ConfigKeySchema { return nil } +func (s *configServiceStub) Values() apiv1.ConfigValuesResponse { + return apiv1.ConfigValuesResponse{} +} +func (s *configServiceStub) PlanManifest(values map[string]any) (apiv1.ConfigManifestPlanResponse, error) { + s.planned = values + return apiv1.ConfigManifestPlanResponse{CurrentRevision: "old", ProposedRevision: "new"}, nil +} +func (s *configServiceStub) ApplyManifest(values map[string]any, expectedRevision, actor string) (apiv1.ConfigManifestApplyResponse, error) { + s.applied = values + s.expected = expectedRevision + s.actor = actor + return apiv1.ConfigManifestApplyResponse{}, s.applyErr +} + +func TestConfigPlanAcceptsCompleteEmptyManifest(t *testing.T) { + stub := &configServiceStub{} + handler := configHandlers{svc: stub} + req := httptest.NewRequest(http.MethodPost, "/v1/config/plan", bytes.NewBufferString(`{"values":{}}`)) + out := httptest.NewRecorder() + + handler.handlePlan(out, req) + + if out.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", out.Code, out.Body.String()) + } + if stub.planned == nil || len(stub.planned) != 0 { + t.Errorf("planned = %#v, want empty non-nil map", stub.planned) + } +} + +func TestConfigApplyPassesRevisionAndSanitizedActor(t *testing.T) { + stub := &configServiceStub{} + handler := configHandlers{svc: stub} + req := httptest.NewRequest(http.MethodPost, "/v1/config/apply", bytes.NewBufferString( + `{"values":{"upload.expiry":"2h"},"expectedRevision":"old"}`, + )) + req.Header.Set("X-Filegate-Actor", " Alice\nAdmin ") + out := httptest.NewRecorder() + + handler.handleApply(out, req) + + if out.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", out.Code, out.Body.String()) + } + if stub.expected != "old" || stub.actor != "Alice Admin" { + t.Errorf("expected=%q actor=%q", stub.expected, stub.actor) + } +} + +func TestConfigApplyMapsStaleRevisionToConflict(t *testing.T) { + stub := &configServiceStub{applyErr: fmt.Errorf("%w: stale manifest", domain.ErrConflict)} + handler := configHandlers{svc: stub} + req := httptest.NewRequest(http.MethodPost, "/v1/config/apply", bytes.NewBufferString( + `{"values":{},"expectedRevision":"old"}`, + )) + out := httptest.NewRecorder() + + handler.handleApply(out, req) + + if out.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", out.Code, out.Body.String()) + } +} + +func TestConfigManifestEndpointsRejectUnknownEnvelopeFields(t *testing.T) { + handler := configHandlers{svc: &configServiceStub{}} + req := httptest.NewRequest(http.MethodPost, "/v1/config/plan", bytes.NewBufferString( + `{"values":{},"changes":{}}`, + )) + out := httptest.NewRecorder() + + handler.handlePlan(out, req) + + if out.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", out.Code) + } +} diff --git a/adapter/http/direct_download.go b/adapter/http/direct_download.go index d1a64f0..55668b9 100644 --- a/adapter/http/direct_download.go +++ b/adapter/http/direct_download.go @@ -6,7 +6,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/netip" "strings" "time" @@ -20,10 +19,9 @@ const ( ) type directDownloadManager struct { - svc *domain.Service - secret []byte - publicURL string - trusted []netip.Prefix + svc *domain.Service + secret []byte + live liveConfig } type directDownloadToken struct { @@ -38,12 +36,11 @@ type directDownloadToken struct { Nonce string `json:"nonce"` } -func newDirectDownloadManager(svc *domain.Service, bearerToken, publicURL string, trusted []netip.Prefix) *directDownloadManager { +func newDirectDownloadManager(svc *domain.Service, bearerToken string, live liveConfig) *directDownloadManager { return &directDownloadManager{ - svc: svc, - secret: []byte(strings.TrimSpace(bearerToken)), - publicURL: strings.TrimRight(strings.TrimSpace(publicURL), "/"), - trusted: append([]netip.Prefix(nil), trusted...), + svc: svc, + secret: []byte(strings.TrimSpace(bearerToken)), + live: live, } } @@ -92,7 +89,7 @@ func (m *directDownloadManager) handleCreate(w http.ResponseWriter, r *http.Requ writeErr(w, http.StatusInternalServerError, "failed to create download url") return } - baseURL, err := directURLBaseForRequest(m.publicURL, m.trusted, r) + baseURL, err := directURLBaseForRequest(m.live.publicURL(), m.live.trustedProxies(), r) if err != nil { writeErr(w, http.StatusBadRequest, "public download URL unavailable") return diff --git a/adapter/http/direct_upload.go b/adapter/http/direct_upload.go index aefcb31..1fae828 100644 --- a/adapter/http/direct_upload.go +++ b/adapter/http/direct_upload.go @@ -25,11 +25,9 @@ const ( ) type directUploadManager struct { - svc *domain.Service - secret []byte - publicURL string - trusted []netip.Prefix - maxUploadBytes int64 + svc *domain.Service + secret []byte + live liveConfig } type directUploadToken struct { @@ -42,16 +40,11 @@ type directUploadToken struct { Nonce string `json:"nonce"` } -func newDirectUploadManager(svc *domain.Service, bearerToken, publicURL string, maxUploadBytes int64, trusted []netip.Prefix) *directUploadManager { - if maxUploadBytes <= 0 { - maxUploadBytes = int64(500 * 1024 * 1024) - } +func newDirectUploadManager(svc *domain.Service, bearerToken string, live liveConfig) *directUploadManager { return &directUploadManager{ - svc: svc, - secret: []byte(strings.TrimSpace(bearerToken)), - publicURL: strings.TrimRight(strings.TrimSpace(publicURL), "/"), - trusted: append([]netip.Prefix(nil), trusted...), - maxUploadBytes: maxUploadBytes, + svc: svc, + secret: []byte(strings.TrimSpace(bearerToken)), + live: live, } } @@ -77,11 +70,12 @@ func (m *directUploadManager) handleCreate(w http.ResponseWriter, r *http.Reques return } + maxUploadBytes := m.live.maxUploadBytes() maxBytes := body.MaxBytes if maxBytes <= 0 { - maxBytes = m.maxUploadBytes + maxBytes = maxUploadBytes } - if maxBytes <= 0 || maxBytes > m.maxUploadBytes { + if maxBytes <= 0 || maxBytes > maxUploadBytes { writeErr(w, http.StatusBadRequest, "maxBytes exceeds upload.max_upload_bytes") return } @@ -109,7 +103,7 @@ func (m *directUploadManager) handleCreate(w http.ResponseWriter, r *http.Reques writeErr(w, http.StatusInternalServerError, "failed to create upload url") return } - baseURL, err := directURLBaseForRequest(m.publicURL, m.trusted, r) + baseURL, err := directURLBaseForRequest(m.live.publicURL(), m.live.trustedProxies(), r) if err != nil { writeErr(w, http.StatusBadRequest, "public upload URL unavailable") return diff --git a/adapter/http/direct_upload_linux_test.go b/adapter/http/direct_upload_linux_test.go index d30c29f..fe4fe2f 100644 --- a/adapter/http/direct_upload_linux_test.go +++ b/adapter/http/direct_upload_linux_test.go @@ -171,7 +171,7 @@ func TestDirectUploadRejectsExpiredToken(t *testing.T) { defer cleanup() root := svc.ListRoot()[0] - direct := newDirectUploadManager(svc, "test-token", "", 1024, nil) + direct := newDirectUploadManager(svc, "test-token", newLiveConfig(RouterOptions{MaxUploadBytes: 1024})) token, err := direct.sign(directUploadToken{ Version: 1, Path: root.Name + "/expired.txt", diff --git a/adapter/http/live.go b/adapter/http/live.go new file mode 100644 index 0000000..196466f --- /dev/null +++ b/adapter/http/live.go @@ -0,0 +1,161 @@ +package httpadapter + +import ( + "net/http" + "net/netip" + "strings" + + "github.com/valentinkolb/filegate/domain" +) + +// liveConfig reads runtime-scoped settings from the published snapshot. +// +// Without this manifest apply would be dishonest: it reports a key as +// runtime-activated, while handlers keep using whatever they captured when the +// router was built. Each accessor falls back to the +// boot-time options when no holder is supplied, which is how the existing +// callers and every current test keep working. +type liveConfig struct { + holder *domain.ConfigHolder + fallback RouterOptions +} + +func newLiveConfig(opts RouterOptions) liveConfig { + return liveConfig{holder: opts.Config, fallback: opts} +} + +// snapshot reports the live configuration and whether one is available. +func (l liveConfig) snapshot() (domain.Config, bool) { + if l.holder == nil { + return domain.Config{}, false + } + return l.holder.Get(), true +} + +func (l liveConfig) maxUploadBytes() int64 { + value := l.fallback.MaxUploadBytes + if cfg, ok := l.snapshot(); ok { + value = cfg.Upload.MaxUploadBytes + } + if value <= 0 { + return 500 << 20 + } + return value +} + +func (l liveConfig) maxChunkBytes() int64 { + value := l.fallback.MaxChunkBytes + if cfg, ok := l.snapshot(); ok { + value = cfg.Upload.MaxChunkBytes + } + if value <= 0 { + return 50 << 20 + } + return value +} + +func (l liveConfig) maxSessionUploadBytes() int64 { + value := l.fallback.MaxSessionUploadBytes + if cfg, ok := l.snapshot(); ok { + value = cfg.Upload.MaxSessionUploadBytes + } + if value <= 0 { + return 50 << 30 + } + return value +} + +func (l liveConfig) uploadMinFreeBytes() int64 { + value := l.fallback.UploadMinFreeBytes + if cfg, ok := l.snapshot(); ok { + value = cfg.Upload.MinFreeBytes + } + if value < 0 { + return 0 + } + return value +} + +func (l liveConfig) publicURL() string { + value := l.fallback.PublicURL + if cfg, ok := l.snapshot(); ok { + value = cfg.Server.PublicURL + } + return strings.TrimRight(strings.TrimSpace(value), "/") +} + +// trustedProxies re-parses on every read. +// +// The parsed form is not stored in the config, and the list is short, so +// parsing per request is cheaper than the bookkeeping needed to cache it. A +// malformed entry cannot appear here: validation rejects it before the snapshot +// is published. +func (l liveConfig) trustedProxies() []netip.Prefix { + cfg, ok := l.snapshot() + if !ok { + return l.fallback.TrustedProxies + } + parsed, err := ParseTrustedProxies(cfg.Server.TrustedProxies) + if err != nil { + return l.fallback.TrustedProxies + } + return parsed +} + +func (l liveConfig) cors() domain.CORSConfig { + if cfg, ok := l.snapshot(); ok { + return cfg.Server.CORS + } + return l.fallback.CORS +} + +func (l liveConfig) accessLogEnabled() bool { + if cfg, ok := l.snapshot(); ok { + return cfg.Server.AccessLogEnabled + } + return l.fallback.AccessLogEnabled +} + +// The middlewares below re-read their settings on every request. Deciding once +// when the chain is built would mean a change to CORS, trusted proxies or +// access logging only applied after a restart, while the schema reported those +// keys as runtime-activated. + +func liveRealIPMiddleware(live liveConfig) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + inner := realIPMiddleware(live.trustedProxies()) + if inner == nil { + next.ServeHTTP(w, r) + return + } + inner(next).ServeHTTP(w, r) + }) + } +} + +func liveCORSMiddleware(live liveConfig) middlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + inner := corsMiddleware(live.cors()) + if inner == nil { + next.ServeHTTP(w, r) + return + } + inner(next).ServeHTTP(w, r) + }) + } +} + +func liveAccessLogMiddleware(live liveConfig) middlewareFunc { + return func(next http.Handler) http.Handler { + logged := accessLogMiddleware(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !live.accessLogEnabled() { + next.ServeHTTP(w, r) + return + } + logged.ServeHTTP(w, r) + }) + } +} diff --git a/adapter/http/live_linux_test.go b/adapter/http/live_linux_test.go new file mode 100644 index 0000000..3b40c91 --- /dev/null +++ b/adapter/http/live_linux_test.go @@ -0,0 +1,91 @@ +//go:build linux + +package httpadapter + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + apiv1 "github.com/valentinkolb/filegate/api/v1" + "github.com/valentinkolb/filegate/domain" +) + +func TestRuntimeUploadConfigReachesManagersAndOperationalEndpoints(t *testing.T) { + initial := domain.Config{ + Server: domain.ServerConfig{PublicURL: "https://old.example.test"}, + Upload: domain.UploadConfig{ + MaxChunkBytes: 1024, + MaxUploadBytes: 2048, + MaxSessionUploadBytes: 4096, + MinFreeBytes: 0, + }, + } + holder := domain.NewConfigHolder(initial) + router, svc, cleanup := newTestRouterWithCustomLimits(t, t.TempDir(), t.TempDir(), RouterOptions{ + BearerToken: "test-token", + Config: holder, + PublicURL: initial.Server.PublicURL, + JobWorkers: 2, + JobQueueSize: 64, + UploadExpiry: time.Hour, + UploadCleanupInterval: time.Hour, + MaxChunkBytes: initial.Upload.MaxChunkBytes, + MaxUploadBytes: initial.Upload.MaxUploadBytes, + MaxSessionUploadBytes: initial.Upload.MaxSessionUploadBytes, + MaxConcurrentSegmentWrites: 4, + }) + defer cleanup() + + updated := initial + updated.Server.PublicURL = "https://new.example.test/" + updated.Upload.MaxChunkBytes = 2048 + updated.Upload.MaxUploadBytes = 8192 + updated.Upload.MaxSessionUploadBytes = 16384 + updated.Upload.MinFreeBytes = 1 + holder.Set(updated) + + capabilities := httptest.NewRecorder() + router.ServeHTTP(capabilities, authedRequest(http.MethodGet, "/v1/capabilities")) + if capabilities.Code != http.StatusOK { + t.Fatalf("capabilities status=%d body=%s", capabilities.Code, capabilities.Body.String()) + } + var caps apiv1.CapabilitiesResponse + if err := json.NewDecoder(capabilities.Body).Decode(&caps); err != nil { + t.Fatalf("decode capabilities: %v", err) + } + if caps.Uploads.MaxChunkBytes != 2048 || caps.Uploads.MaxUploadBytes != 8192 || caps.Uploads.MaxSessionUploadBytes != 16384 { + t.Fatalf("capabilities still expose startup limits: %#v", caps.Uploads) + } + + infoResponse := httptest.NewRecorder() + router.ServeHTTP(infoResponse, authedRequest(http.MethodGet, "/v1/system/info")) + if infoResponse.Code != http.StatusOK { + t.Fatalf("system info status=%d body=%s", infoResponse.Code, infoResponse.Body.String()) + } + var info apiv1.SystemInfoResponse + if err := json.NewDecoder(infoResponse.Body).Decode(&info); err != nil { + t.Fatalf("decode system info: %v", err) + } + if info.Limits.UploadMinFreeBytes != 1 { + t.Fatalf("upload min free bytes=%d, want 1", info.Limits.UploadMinFreeBytes) + } + + root := svc.ListRoot()[0] + create := httptest.NewRecorder() + body := []byte(`{"path":"` + root.Name + `/runtime.bin","maxBytes":4096,"expiresInSeconds":60}`) + router.ServeHTTP(create, authedJSONRequest(http.MethodPost, "/v1/uploads/direct", body)) + if create.Code != http.StatusCreated { + t.Fatalf("direct upload status=%d body=%s", create.Code, create.Body.String()) + } + var direct apiv1.DirectUploadURLResponse + if err := json.NewDecoder(create.Body).Decode(&direct); err != nil { + t.Fatalf("decode direct upload: %v", err) + } + if !strings.HasPrefix(direct.UploadURL, "https://new.example.test/v1/uploads/direct/") { + t.Fatalf("direct upload URL=%q", direct.UploadURL) + } +} diff --git a/adapter/http/router.go b/adapter/http/router.go index 6538809..3c18d9d 100644 --- a/adapter/http/router.go +++ b/adapter/http/router.go @@ -30,6 +30,7 @@ import ( apiv1 "github.com/valentinkolb/filegate/api/v1" "github.com/valentinkolb/filegate/domain" "github.com/valentinkolb/filegate/infra/activity" + "github.com/valentinkolb/filegate/infra/detect" "github.com/valentinkolb/filegate/infra/jobs" ) @@ -70,6 +71,41 @@ type RouterOptions struct { MetricsPath string MetricsToken string ActivityLog *activity.Ring + + // Config is the live snapshot. Runtime-scoped handlers read from it per + // request so a change applies without a restart; nil falls back to the + // values captured in this struct, which keeps existing callers working. + Config *domain.ConfigHolder + // Lifecycle reports the last background maintenance run. Nil reports zeroes. + Lifecycle func() apiv1.LifecycleRuntime + // PruneNow runs a retention round on demand. Nil leaves the route + // answering 501, which is the honest response when versioning is off. + PruneNow func() (domain.PruneStats, error) + // ConfigService backs the /v1/config endpoints. Nil leaves them unmounted, + // which is how every existing router caller and test keeps working. + ConfigService ConfigService + // S3Keys backs the /v1/s3/keys endpoints. Nil leaves them unmounted. + S3Keys S3KeyService + + // Operational context for GET /v1/system/info, /v1/system/runtime and + // /v1/health. All optional: zero values degrade the reported detail + // rather than breaking the endpoints, which keeps existing router + // callers (including tests) working unchanged. + BuildVersion string + BuildCommit string + BasePaths []string + // PathCacheSize is the configured capacity, reported alongside the live + // occupancy the service tracks. + PathCacheSize int + // DetectorStats returns live detector state. Nil means the router reports + // an unknown backend instead of guessing. + DetectorStats func() detect.Stats + + VersioningEnabled bool + VersioningMode string + VersioningCooldown time.Duration + VersioningPrunerInterval time.Duration + VersioningMaxPinnedPerFile int } type closeableHandler struct { @@ -121,24 +157,21 @@ func (h *closeableHandler) Close() error { // NewRouter constructs the HTTP handler tree with all routes, middleware, and background workers. func NewRouter(svc *domain.Service, opts RouterOptions) http.Handler { root := http.NewServeMux() + live := newLiveConfig(opts) thumbnailWorkers := resolveThumbnailJobWorkers(opts) thumbnailQueueSize := resolveThumbnailQueueSize(opts) thumbnailScheduler := jobs.New(thumbnailWorkers, thumbnailQueueSize) - directUploads := newDirectUploadManager(svc, opts.BearerToken, opts.PublicURL, opts.MaxUploadBytes, opts.TrustedProxies) - directDownloads := newDirectDownloadManager(svc, opts.BearerToken, opts.PublicURL, opts.TrustedProxies) + directUploads := newDirectUploadManager(svc, opts.BearerToken, live) + directDownloads := newDirectDownloadManager(svc, opts.BearerToken, live) uploadSessions := newUploadSessionManager( svc, opts.BearerToken, - opts.PublicURL, - opts.MaxChunkBytes, - opts.MaxSessionUploadBytes, + live, opts.MaxConcurrentSegmentWrites, - opts.UploadMinFreeBytes, opts.UploadExpiry, opts.UploadCleanupInterval, - opts.TrustedProxies, ) thumbs := newThumbnailer( svc, @@ -173,11 +206,35 @@ func NewRouter(svc *domain.Service, opts RouterOptions) http.Handler { root.HandleFunc("POST /v1/uploads/sessions/{sessionId}/commit", uploadSessions.handleCommit) root.HandleFunc("DELETE /v1/uploads/sessions/{sessionId}", uploadSessions.handleAbort) - auth := authMiddleware(opts.BearerToken) + auth := authMiddleware(opts.BearerToken, opts.ActivityLog) handleV1 := func(pattern string, handler http.HandlerFunc) { root.Handle(pattern, auth(http.HandlerFunc(handler))) } + system := newSystemReporter(svc, opts, live, thumbs, uploadSessions) + handleV1("GET /v1/system/info", system.handleInfo) + handleV1("GET /v1/system/runtime", system.handleRuntime) + handleV1("GET /v1/health", system.handleHealth) + handleV1("GET /v1/uploads/sessions", system.handleListUploadSessions) + handleV1("POST /v1/versions/prune", system.handlePrune) + + if opts.ConfigService != nil { + cfgAPI := configHandlers{svc: opts.ConfigService} + handleV1("GET /v1/config/schema", cfgAPI.handleSchema) + handleV1("GET /v1/config", cfgAPI.handleValues) + handleV1("POST /v1/config/plan", cfgAPI.handlePlan) + handleV1("POST /v1/config/apply", cfgAPI.handleApply) + } + + if opts.S3Keys != nil { + keysAPI := s3KeyHandlers{svc: opts.S3Keys} + handleV1("GET /v1/s3/keys", keysAPI.handleList) + handleV1("POST /v1/s3/keys", keysAPI.handleCreate) + handleV1("PATCH /v1/s3/keys/{accessKey}", keysAPI.handleUpdate) + handleV1("POST /v1/s3/keys/{accessKey}/rotate", keysAPI.handleRotate) + handleV1("DELETE /v1/s3/keys/{accessKey}", keysAPI.handleDelete) + } + handleV1("GET /v1/stats", func(w http.ResponseWriter, _ *http.Request) { stats, err := svc.Stats() if err != nil { @@ -246,9 +303,9 @@ func NewRouter(svc *domain.Service, opts RouterOptions) http.Handler { handleV1("GET /v1/capabilities", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, apiv1.CapabilitiesResponse{ Uploads: apiv1.UploadCapabilities{ - MaxChunkBytes: uploadSessions.maxSegmentBytes, - MaxUploadBytes: opts.MaxUploadBytes, - MaxSessionUploadBytes: uploadSessions.maxUploadBytes, + MaxChunkBytes: live.maxChunkBytes(), + MaxUploadBytes: live.maxUploadBytes(), + MaxSessionUploadBytes: live.maxSessionUploadBytes(), MaxConcurrentSegmentWrites: uploadSessions.maxWrites, }, }) @@ -305,7 +362,7 @@ func NewRouter(svc *domain.Service, opts RouterOptions) http.Handler { statusFromErr(w, err) return } - r.Body = http.MaxBytesReader(w, r.Body, opts.MaxUploadBytes) + r.Body = http.MaxBytesReader(w, r.Body, live.maxUploadBytes()) meta, created, err := svc.WriteContentByVirtualPath(vp, r.Body, mode) if err != nil { if errors.Is(err, domain.ErrConflict) { @@ -390,7 +447,7 @@ func NewRouter(svc *domain.Service, opts RouterOptions) http.Handler { return } - r.Body = http.MaxBytesReader(w, r.Body, opts.MaxUploadBytes) + r.Body = http.MaxBytesReader(w, r.Body, live.maxUploadBytes()) if err := svc.WriteContent(id, r.Body); err != nil { statusFromErr(w, err) return @@ -628,16 +685,10 @@ func NewRouter(svc *domain.Service, opts RouterOptions) http.Handler { writeJSON(w, http.StatusOK, apiv1.IndexResolveManyResponse{Items: items, Total: len(items)}) }) chain := []middlewareFunc{recoverMiddleware, requestIDMiddleware, activityMiddleware(opts.ActivityLog)} - if realIP := realIPMiddleware(opts.TrustedProxies); realIP != nil { - chain = append(chain, realIP) - } + chain = append(chain, liveRealIPMiddleware(live)) chain = append(chain, secureHeadersMiddleware) - if cors := corsMiddleware(opts.CORS); cors != nil { - chain = append(chain, cors) - } - if opts.AccessLogEnabled { - chain = append(chain, accessLogMiddleware) - } + chain = append(chain, liveCORSMiddleware(live)) + chain = append(chain, liveAccessLogMiddleware(live)) handler := chainMiddleware(root, chain...) return &closeableHandler{ handler: handler, @@ -1441,6 +1492,12 @@ func restOperationName(method, path string) string { switch { case method == http.MethodPost && path == "/v1/index/rescan": return "index.rescan" + case method == http.MethodPost && path == "/v1/versions/prune": + return "versions.prune" + case method == http.MethodPost && path == "/v1/config/plan": + return "config.plan" + case method == http.MethodPost && path == "/v1/config/apply": + return "config.apply" case method == http.MethodPost && path == "/v1/uploads/direct": return "direct_upload.create_url" case method == http.MethodPost && path == "/v1/downloads/direct": @@ -1853,20 +1910,43 @@ func metricsAuthMiddleware(metricsToken, bearerToken string) func(http.Handler) } } -func authMiddleware(token string) func(http.Handler) http.Handler { +// recordAuthFailure logs a rejected request to the activity ring. +// +// The activity middleware only records requests whose actor could be +// determined, so authentication failures previously left no trace at all -- +// exactly the events an operator investigating an intrusion wants to see. The +// actor is "system" because there is, by definition, no authenticated identity. +func recordAuthFailure(ring *activity.Ring, r *http.Request, reason string) { + if ring == nil { + return + } + ring.Record(activity.Event{ + Actor: activity.Actor{Kind: activity.ActorSystem, ID: "anonymous"}, + Operation: "auth.denied", + Outcome: activity.OutcomeFailed, + Target: &activity.Target{Kind: "path", Path: r.URL.Path}, + RequestID: requestID(r), + Error: reason, + }) +} + +func authMiddleware(token string, ring *activity.Ring) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := strings.TrimSpace(r.Header.Get("Authorization")) if token == "" { + recordAuthFailure(ring, r, "bearer token not configured") writeErr(w, http.StatusUnauthorized, "bearer token not configured") return } if !strings.HasPrefix(auth, "Bearer ") { + recordAuthFailure(ring, r, "missing bearer token") writeErr(w, http.StatusUnauthorized, "missing bearer token") return } provided := strings.TrimPrefix(auth, "Bearer ") if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { + recordAuthFailure(ring, r, "invalid bearer token") writeErr(w, http.StatusUnauthorized, "invalid bearer token") return } @@ -2061,6 +2141,16 @@ func nodeResponseForFingerprint(meta *domain.FileMeta, mode fingerprintMode) api } func statusFromErr(w http.ResponseWriter, err error) { + // A body that exceeded upload.max_upload_bytes is the client's problem, + // not a server fault. Only the direct-upload handler used to translate + // this, so path and node writes answered 500 and told the caller nothing + // actionable. + var maxBytes *http.MaxBytesError + if errors.As(err, &maxBytes) { + writeErr(w, http.StatusRequestEntityTooLarge, "upload exceeds upload.max_upload_bytes") + return + } + switch { case errors.Is(err, domain.ErrNotFound): writeErr(w, http.StatusNotFound, "not found") diff --git a/adapter/http/router_h2c_linux_test.go b/adapter/http/router_h2c_linux_test.go new file mode 100644 index 0000000..6ed6cea --- /dev/null +++ b/adapter/http/router_h2c_linux_test.go @@ -0,0 +1,166 @@ +//go:build linux + +package httpadapter + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// h2cOnlyClient speaks cleartext HTTP/2 and nothing else, so a downgrade shows +// up as a failure instead of a passing test that proved nothing. +func h2cOnlyClient() *http.Client { + protocols := new(http.Protocols) + protocols.SetUnencryptedHTTP2(true) + return &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{Protocols: protocols}} +} + +func h2cRouterServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + server := httptest.NewUnstartedServer(handler) + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + server.Config.Protocols = protocols + server.Start() + t.Cleanup(server.Close) + return server +} + +// The real routes work over h2c, not just a stub handler. +// +// The upload paths read Content-Length and wrap bodies in MaxBytesReader, and +// HTTP/2 does not require a client to send a length at all. A body that arrived +// truncated over h2c would land a corrupt file rather than fail a request, so +// this asserts the stored bytes. +func TestOneShotUploadOverH2C(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + server := h2cRouterServer(t, r) + + root := svc.ListRoot()[0] + payload := bytes.Repeat([]byte("h2c-payload;"), 200_000) // ~2.4 MiB, several flow-control windows + + // No Content-Length: the body is an unknown-length reader, which is the + // case HTTP/2 allows and HTTP/1.1 would have chunked. + req, err := http.NewRequest(http.MethodPut, server.URL+"/v1/paths/"+root.Name+"/h2c/upload.bin", newUnsizedReader(payload)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer test-token") + + res, err := h2cOnlyClient().Do(req) + if err != nil { + t.Fatalf("h2c put: %v", err) + } + defer res.Body.Close() + if res.ProtoMajor != 2 { + t.Fatalf("request went over %s, so this test proves nothing about h2c", res.Proto) + } + if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(res.Body) + t.Fatalf("put status=%d body=%s", res.StatusCode, body) + } + + id, err := svc.ResolvePath(root.Name + "/h2c/upload.bin") + if err != nil { + t.Fatalf("uploaded file does not resolve: %v", err) + } + meta, err := svc.GetFile(id) + if err != nil { + t.Fatalf("get file: %v", err) + } + if meta.Size != int64(len(payload)) { + t.Fatalf("stored %d bytes, want %d", meta.Size, len(payload)) + } + + // Read it back over h2c too, so the download path is covered as well. + getReq, err := http.NewRequest(http.MethodGet, server.URL+"/v1/nodes/"+id.String()+"/content", nil) + if err != nil { + t.Fatalf("new get request: %v", err) + } + getReq.Header.Set("Authorization", "Bearer test-token") + getRes, err := h2cOnlyClient().Do(getReq) + if err != nil { + t.Fatalf("h2c get: %v", err) + } + defer getRes.Body.Close() + got, err := io.ReadAll(getRes.Body) + if err != nil { + t.Fatalf("read downloaded body: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("downloaded %d bytes, want %d, and they differ", len(got), len(payload)) + } +} + +// The upload size limit still applies over h2c. +// +// It cannot lean on Content-Length here, because a client need not send one, so +// this is the case where MaxBytesReader has to be what stops the request. A +// missing limit would let a client write past max_upload_bytes. +func TestOversizedUploadOverH2CIsRefused(t *testing.T) { + r, svc, cleanup := newTestRouterWithCustomLimits(t, t.TempDir(), t.TempDir(), RouterOptions{ + BearerToken: "test-token", + JobWorkers: 2, + JobQueueSize: 64, + UploadExpiry: time.Hour, + UploadCleanupInterval: time.Hour, + MaxChunkBytes: 1 << 20, + MaxUploadBytes: 64 << 10, + }) + defer cleanup() + server := h2cRouterServer(t, r) + + root := svc.ListRoot()[0] + oversized := bytes.Repeat([]byte("x"), 256<<10) // four times the limit + + req, err := http.NewRequest(http.MethodPut, server.URL+"/v1/paths/"+root.Name+"/h2c/too-big.bin", newUnsizedReader(oversized)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer test-token") + + res, err := h2cOnlyClient().Do(req) + if err != nil { + // A stream reset is an acceptable way to refuse an oversized body; what + // matters is that nothing was stored. + t.Logf("h2c put failed at the transport: %v", err) + } else { + defer res.Body.Close() + if res.ProtoMajor != 2 { + t.Fatalf("request went over %s, so this test proves nothing about h2c", res.Proto) + } + if res.StatusCode != http.StatusRequestEntityTooLarge { + body, _ := io.ReadAll(res.Body) + t.Errorf("status=%d body=%s, want 413", res.StatusCode, body) + } + } + + if _, err := svc.ResolvePath(root.Name + "/h2c/too-big.bin"); err == nil { + t.Error("an oversized upload was stored") + } +} + +// newUnsizedReader hides the length from net/http so no Content-Length is sent. +func newUnsizedReader(payload []byte) io.Reader { + return &unsizedReader{payload: payload} +} + +type unsizedReader struct { + payload []byte + offset int +} + +func (r *unsizedReader) Read(p []byte) (int, error) { + if r.offset >= len(r.payload) { + return 0, io.EOF + } + n := copy(p, r.payload[r.offset:]) + r.offset += n + return n, nil +} diff --git a/adapter/http/router_security_linux_test.go b/adapter/http/router_security_linux_test.go index 4ddf4ec..cf9f3c5 100644 --- a/adapter/http/router_security_linux_test.go +++ b/adapter/http/router_security_linux_test.go @@ -125,6 +125,47 @@ func TestAuthMiddlewareFailsClosedWhenTokenNotConfigured(t *testing.T) { } } +func TestConfigRoutesExposeManifestWorkflowOnly(t *testing.T) { + stub := &configServiceStub{} + r, _, cleanup := newTestRouterWithCustomLimits(t, t.TempDir(), t.TempDir(), RouterOptions{ + BearerToken: "test-token", + JobWorkers: 1, + JobQueueSize: 8, + UploadExpiry: time.Hour, + UploadCleanupInterval: time.Hour, + MaxChunkBytes: 1 << 20, + MaxUploadBytes: 10 << 20, + ConfigService: stub, + }) + defer cleanup() + + plan := authedRequest(http.MethodPost, "/v1/config/plan") + plan.Body = io.NopCloser(strings.NewReader(`{"values":{}}`)) + plan.Header.Set("Content-Type", "application/json") + out := httptest.NewRecorder() + r.ServeHTTP(out, plan) + if out.Code != http.StatusOK { + t.Fatalf("plan status=%d body=%s", out.Code, out.Body.String()) + } + + for _, route := range []struct { + method string + path string + }{ + {http.MethodPatch, "/v1/config"}, + {http.MethodPost, "/v1/config/validate"}, + {http.MethodPost, "/v1/config/reload"}, + } { + req := authedRequest(route.method, route.path) + req.Body = io.NopCloser(strings.NewReader(`{"changes":{"upload.expiry":"1h"}}`)) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { + t.Errorf("%s %s status=%d, want removed route", route.method, route.path, rec.Code) + } + } +} + func TestPathTraversalBlocked(t *testing.T) { r, _, cleanup := newTestRouter(t) defer cleanup() diff --git a/adapter/http/router_versions.go b/adapter/http/router_versions.go index c5ee2ae..feabc21 100644 --- a/adapter/http/router_versions.go +++ b/adapter/http/router_versions.go @@ -10,9 +10,8 @@ import ( "github.com/valentinkolb/filegate/domain" ) -// registerVersionRoutes wires the per-file version endpoints onto the -// existing router. Read paths (this file) ship in Phase 3; mutation -// paths (snapshot/pin/unpin/restore/delete) follow in later phases. +// registerVersionRoutes wires the per-file version read and mutation endpoints +// onto the existing router. func registerVersionRoutes(handleV1 func(string, http.HandlerFunc), svc *domain.Service) { handleV1("GET /v1/nodes/{id}/versions", func(w http.ResponseWriter, r *http.Request) { id, ok := parseID(w, r.PathValue("id")) diff --git a/adapter/http/s3keys.go b/adapter/http/s3keys.go new file mode 100644 index 0000000..211f252 --- /dev/null +++ b/adapter/http/s3keys.go @@ -0,0 +1,85 @@ +package httpadapter + +import ( + "encoding/json" + "net/http" + + apiv1 "github.com/valentinkolb/filegate/api/v1" +) + +// S3KeyService is the access-key surface the router exposes. Implemented in +// the CLI package, which owns the runtime store. +type S3KeyService interface { + List() ([]apiv1.S3Key, error) + Create(req apiv1.S3KeyCreateRequest) (apiv1.S3KeyCreated, error) + Rotate(accessKey string) (apiv1.S3KeyCreated, error) + Update(accessKey string, req apiv1.S3KeyUpdateRequest) (apiv1.S3Key, error) + Delete(accessKey string) error +} + +type s3KeyHandlers struct { + svc S3KeyService +} + +func (h s3KeyHandlers) handleList(w http.ResponseWriter, _ *http.Request) { + keys, err := h.svc.List() + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, apiv1.S3KeyListResponse{Items: keys, Total: len(keys)}) +} + +// handleCreate returns the secret, which is the only time it is ever readable. +func (h s3KeyHandlers) handleCreate(w http.ResponseWriter, r *http.Request) { + var req apiv1.S3KeyCreateRequest + if !decodeStrict(w, r, &req) { + return + } + created, err := h.svc.Create(req) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (h s3KeyHandlers) handleRotate(w http.ResponseWriter, r *http.Request) { + rotated, err := h.svc.Rotate(r.PathValue("accessKey")) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + writeJSON(w, http.StatusOK, rotated) +} + +func (h s3KeyHandlers) handleUpdate(w http.ResponseWriter, r *http.Request) { + var req apiv1.S3KeyUpdateRequest + if !decodeStrict(w, r, &req) { + return + } + updated, err := h.svc.Update(r.PathValue("accessKey"), req) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, updated) +} + +func (h s3KeyHandlers) handleDelete(w http.ResponseWriter, r *http.Request) { + if err := h.svc.Delete(r.PathValue("accessKey")); err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func decodeStrict(w http.ResponseWriter, r *http.Request, out any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(out); err != nil { + writeErr(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return false + } + return true +} diff --git a/adapter/http/system.go b/adapter/http/system.go new file mode 100644 index 0000000..9b329bf --- /dev/null +++ b/adapter/http/system.go @@ -0,0 +1,416 @@ +package httpadapter + +import ( + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + apiv1 "github.com/valentinkolb/filegate/api/v1" + "github.com/valentinkolb/filegate/domain" + "github.com/valentinkolb/filegate/infra/cache" + "github.com/valentinkolb/filegate/infra/detect" + "github.com/valentinkolb/filegate/infra/filesystem" + "github.com/valentinkolb/filegate/infra/jobs" +) + +// systemReporter answers the operational endpoints. It reads state that the +// server already tracks; nothing here mutates anything. +type systemReporter struct { + svc *domain.Service + opts RouterOptions + live liveConfig + thumbs *thumbnailer + uploads *uploadSessionManager + startedAt time.Time +} + +func newSystemReporter(svc *domain.Service, opts RouterOptions, live liveConfig, thumbs *thumbnailer, uploads *uploadSessionManager) *systemReporter { + return &systemReporter{ + svc: svc, + opts: opts, + live: live, + thumbs: thumbs, + uploads: uploads, + startedAt: time.Now(), + } +} + +func (r *systemReporter) detectorStats() detect.Stats { + if r.opts.DetectorStats == nil { + return detect.Stats{Backend: "unknown"} + } + return r.opts.DetectorStats() +} + +// handleInfo serves GET /v1/system/info. It probes mount health, so it touches +// the filesystem and is meant to be read occasionally rather than polled. +func (r *systemReporter) handleInfo(w http.ResponseWriter, _ *http.Request) { + now := time.Now() + detector := r.detectorStats() + + info := apiv1.SystemInfoResponse{ + GeneratedAt: now.UnixMilli(), + Build: apiv1.BuildInfo{ + Version: fallback(r.opts.BuildVersion, "dev"), + Commit: fallback(r.opts.BuildCommit, "none"), + Go: runtime.Version(), + }, + StartedAt: r.startedAt.UnixMilli(), + UptimeMs: now.Sub(r.startedAt).Milliseconds(), + Detector: apiv1.DetectorInfo{ + Backend: detector.Backend, + IntervalMs: detector.Interval.Milliseconds(), + }, + Versioning: apiv1.VersioningInfo{ + Enabled: r.opts.VersioningEnabled, + Mode: fallback(r.opts.VersioningMode, "auto"), + CooldownMs: r.opts.VersioningCooldown.Milliseconds(), + PrunerIntervalMs: r.opts.VersioningPrunerInterval.Milliseconds(), + MaxPinnedPerFile: r.opts.VersioningMaxPinnedPerFile, + }, + Limits: apiv1.LimitsInfo{ + MaxChunkBytes: r.live.maxChunkBytes(), + MaxUploadBytes: r.live.maxUploadBytes(), + MaxSessionUploadBytes: r.live.maxSessionUploadBytes(), + MaxConcurrentSegmentWrites: r.opts.MaxConcurrentSegmentWrites, + UploadMinFreeBytes: r.live.uploadMinFreeBytes(), + UploadExpiryMs: r.opts.UploadExpiry.Milliseconds(), + UploadCleanupIntervalMs: r.opts.UploadCleanupInterval.Milliseconds(), + ThumbnailMaxSourceBytes: r.opts.ThumbnailMaxSourceBytes, + ThumbnailMaxPixels: r.opts.ThumbnailMaxPixels, + PathCacheCapacity: r.opts.PathCacheSize, + ActivityRingCapacity: r.opts.ActivityLog.Capacity(), + }, + Mounts: r.mountInfo(), + IndexPath: r.opts.IndexPath, + } + + writeJSON(w, http.StatusOK, info) +} + +func (r *systemReporter) mountInfo() []apiv1.MountInfo { + paths := r.opts.BasePaths + out := make([]apiv1.MountInfo, 0, len(paths)) + for _, health := range filesystem.CheckMountsHealth(paths) { + out = append(out, apiv1.MountInfo{ + Name: filepath.Base(health.Path), + Path: health.Path, + Exists: health.Exists, + Writable: health.Writable, + XAttrSupported: health.XAttrSupported, + FreeBytes: health.FreeBytes, + TotalBytes: health.TotalBytes, + Errors: health.Errors, + }) + } + return out +} + +// handleRuntime serves GET /v1/system/runtime. Every value is an in-memory +// counter, so this is the endpoint a dashboard should poll. +func (r *systemReporter) handleRuntime(w http.ResponseWriter, _ *http.Request) { + now := time.Now() + detector := r.detectorStats() + + staleFor := int64(0) + if !detector.LastScanAt.IsZero() { + staleFor = now.Sub(detector.LastScanAt).Milliseconds() + } + lastScanAt := int64(0) + if !detector.LastScanAt.IsZero() { + lastScanAt = detector.LastScanAt.UnixMilli() + } + + pathEntries, pathCapacity, pathHits, pathMisses := r.svc.PathCacheStats() + + out := apiv1.SystemRuntimeResponse{ + GeneratedAt: now.UnixMilli(), + Detector: apiv1.DetectorRuntime{ + Backend: detector.Backend, + IntervalMs: detector.Interval.Milliseconds(), + Cycles: detector.Cycles, + LastScanAt: lastScanAt, + LastScanDurationMs: detector.LastScanDuration.Milliseconds(), + StaleForMs: staleFor, + Errors: detector.Errors, + PendingBatches: detector.PendingBatches, + QueueCapacity: detector.QueueCapacity, + TrackedDirs: detector.TrackedDirs, + TrackedFiles: detector.TrackedFiles, + Generations: detector.Generations, + }, + Jobs: jobsRuntime(r.thumbs.schedulerStats()), + PathCache: cacheRuntime(pathEntries, pathCapacity, pathHits, pathMisses), + ThumbnailCache: thumbCacheRuntime(r.thumbs.cacheStats()), + UploadSessions: r.uploadSessionRuntime(), + Lifecycle: r.lifecycleRuntime(), + } + + writeJSON(w, http.StatusOK, out) +} + +func (r *systemReporter) lifecycleRuntime() apiv1.LifecycleRuntime { + if r.opts.Lifecycle == nil { + return apiv1.LifecycleRuntime{} + } + return r.opts.Lifecycle() +} + +func (r *systemReporter) uploadSessionRuntime() apiv1.UploadSessionsRuntime { + out := apiv1.UploadSessionsRuntime{ + WriteSlotsInUse: r.uploads.writeSlotsInUse(), + WriteSlotsLimit: r.uploads.writeSlotsLimit(), + } + counts := map[domain.UploadSessionPhase]*int{ + domain.UploadSessionInProgress: &out.InProgress, + domain.UploadSessionCommitting: &out.Committing, + domain.UploadSessionCommitted: &out.Committed, + domain.UploadSessionAborted: &out.Aborted, + } + for phase, target := range counts { + sessions, err := r.svc.ListUploadSessions(phase) + if err != nil { + continue + } + *target = len(sessions) + } + return out +} + +// handleHealth serves GET /v1/health: a real dependency check, unlike the bare +// GET /health liveness probe which only proves the process is listening. +func (r *systemReporter) handleHealth(w http.ResponseWriter, _ *http.Request) { + checks := make([]apiv1.HealthCheck, 0, 3) + status := apiv1.HealthOK + + degrade := func(to string) { + if to == apiv1.HealthFail { + status = apiv1.HealthFail + return + } + if status == apiv1.HealthOK { + status = apiv1.HealthDegraded + } + } + + // Index: a point lookup is the cheapest proof that Pebble answers. Stats + // would also prove it but walks every entity. + if err := r.svc.PingIndex(); err != nil { + checks = append(checks, apiv1.HealthCheck{Name: "index", Status: apiv1.HealthFail, Detail: err.Error()}) + degrade(apiv1.HealthFail) + } else { + checks = append(checks, apiv1.HealthCheck{Name: "index", Status: apiv1.HealthOK}) + } + + // Detector: silence well past the scan interval means the goroutine died, + // which causes silent index drift rather than an obvious outage. + checks = append(checks, r.detectorHealth(°rade)) + + // Mounts: existence only. Writability needs a write probe, which belongs on + // the occasional /v1/system/info rather than on a pollable health endpoint. + if missing := missingMounts(r.opts.BasePaths); len(missing) > 0 { + checks = append(checks, apiv1.HealthCheck{Name: "mounts", Status: apiv1.HealthFail, Detail: "unreachable: " + joinPaths(missing)}) + degrade(apiv1.HealthFail) + } else { + checks = append(checks, apiv1.HealthCheck{Name: "mounts", Status: apiv1.HealthOK}) + } + + code := http.StatusOK + if status == apiv1.HealthFail { + code = http.StatusServiceUnavailable + } + writeJSON(w, code, apiv1.HealthResponse{ + Status: status, + GeneratedAt: time.Now().UnixMilli(), + Checks: checks, + }) +} + +// detectorStaleFactor is how many scan intervals may elapse before detection is +// considered stalled. Scans can overrun their interval under load, so a small +// multiple avoids flapping while still catching a dead goroutine quickly. +const detectorStaleFactor = 5 + +func (r *systemReporter) detectorHealth(degrade *func(string)) apiv1.HealthCheck { + stats := r.detectorStats() + if stats.Interval <= 0 { + return apiv1.HealthCheck{Name: "detector", Status: apiv1.HealthOK, Detail: "not configured"} + } + if stats.LastScanAt.IsZero() { + // Startup has not completed a first round yet. Not an error on its own. + return apiv1.HealthCheck{Name: "detector", Status: apiv1.HealthOK, Detail: "awaiting first scan"} + } + + stale := time.Since(stats.LastScanAt) + if stale > stats.Interval*detectorStaleFactor { + (*degrade)(apiv1.HealthDegraded) + return apiv1.HealthCheck{ + Name: "detector", + Status: apiv1.HealthDegraded, + Detail: "no scan for " + stale.Round(time.Second).String() + "; external filesystem changes may not be indexed", + } + } + return apiv1.HealthCheck{Name: "detector", Status: apiv1.HealthOK} +} + +// handlePrune serves POST /v1/versions/prune. +// +// A manual trigger exists because the background loop runs on an interval an +// operator cannot see the effect of: after tightening a retention policy, the +// obvious next question is whether it did anything. +// +// This deletes data, so it is a POST, it is recorded in the activity log by the +// middleware, and it refuses to start while a round is already in flight rather +// than doubling the work. +func (r *systemReporter) handlePrune(w http.ResponseWriter, _ *http.Request) { + if r.opts.PruneNow == nil { + writeErr(w, http.StatusNotImplemented, "manual pruning is not available") + return + } + + started := time.Now() + stats, err := r.opts.PruneNow() + if err != nil { + if strings.Contains(err.Error(), "already in progress") { + writeErr(w, http.StatusConflict, err.Error()) + return + } + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + + writeJSON(w, http.StatusOK, apiv1.PruneResponse{ + FilesScanned: stats.FilesScanned, + VersionsKept: stats.VersionsKept, + VersionsDeleted: stats.VersionsDeleted, + OrphansPurged: stats.OrphansPurged, + BlobsDeleted: stats.BlobsDeleted, + Errors: stats.Errors, + DurationMs: time.Since(started).Milliseconds(), + }) +} + +// handleListUploadSessions serves GET /v1/uploads/sessions. Without it an +// interrupted upload leaves a session that nothing can find, only abort by id. +func (r *systemReporter) handleListUploadSessions(w http.ResponseWriter, req *http.Request) { + phases := []domain.UploadSessionPhase{ + domain.UploadSessionInProgress, + domain.UploadSessionCommitting, + domain.UploadSessionCommitted, + domain.UploadSessionAborted, + } + if requested := req.URL.Query().Get("phase"); requested != "" { + phase := domain.UploadSessionPhase(requested) + if !validUploadPhase(phase) { + writeErr(w, http.StatusBadRequest, "phase must be one of in_progress, committing, committed, aborted") + return + } + phases = []domain.UploadSessionPhase{phase} + } + + now := time.Now().UnixMilli() + items := make([]apiv1.UploadSessionSummary, 0, 16) + for _, phase := range phases { + sessions, err := r.svc.ListUploadSessions(phase) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + for _, session := range sessions { + items = append(items, r.summarizeSession(session, now)) + } + } + + writeJSON(w, http.StatusOK, apiv1.UploadSessionListResponse{Items: items, Total: len(items)}) +} + +func (r *systemReporter) summarizeSession(session domain.UploadSession, now int64) apiv1.UploadSessionSummary { + uploaded := 0 + var uploadedBytes int64 + if segments, err := r.svc.ListUploadSegments(session.ID); err == nil { + uploaded = len(segments) + for _, segment := range segments { + uploadedBytes += segment.Size + } + } + return apiv1.UploadSessionSummary{ + ID: session.ID, + Path: session.Path, + Size: session.Size, + SegmentSize: session.SegmentSize, + TotalSegments: session.TotalSegments, + UploadedSegments: uploaded, + UploadedBytes: uploadedBytes, + Phase: string(session.Phase), + CreatedAt: session.CreatedAt, + UpdatedAt: session.UpdatedAt, + AgeMs: now - session.CreatedAt, + ContentType: session.ContentType, + } +} + +func validUploadPhase(phase domain.UploadSessionPhase) bool { + switch phase { + case domain.UploadSessionInProgress, domain.UploadSessionCommitting, domain.UploadSessionCommitted, domain.UploadSessionAborted: + return true + default: + return false + } +} + +func jobsRuntime(stats jobs.Stats) apiv1.JobsRuntime { + return apiv1.JobsRuntime{ + Workers: stats.Workers, + Queued: stats.Queued, + QueueCapacity: stats.QueueCapacity, + InFlight: stats.InFlight, + Rejected: stats.Rejected, + Panics: stats.Panics, + } +} + +func thumbCacheRuntime(stats cache.Stats) apiv1.CacheRuntime { + return cacheRuntime(stats.Entries, stats.Capacity, stats.Hits, stats.Misses) +} + +// missingMounts returns the configured mounts that cannot be reached at all. +// Existence only: a write probe belongs on /v1/system/info, not on an endpoint +// meant to be polled. +func missingMounts(paths []string) []string { + var missing []string + for _, path := range paths { + if info, err := os.Stat(path); err != nil || !info.IsDir() { + missing = append(missing, path) + } + } + return missing +} + +func cacheRuntime(entries, capacity int, hits, misses uint64) apiv1.CacheRuntime { + ratio := 0.0 + if total := hits + misses; total > 0 { + ratio = float64(hits) / float64(total) + } + return apiv1.CacheRuntime{Entries: entries, Capacity: capacity, Hits: hits, Misses: misses, HitRatio: ratio} +} + +func fallback(value, def string) string { + if value == "" { + return def + } + return value +} + +func joinPaths(paths []string) string { + out := "" + for i, path := range paths { + if i > 0 { + out += ", " + } + out += path + } + return out +} diff --git a/adapter/http/system_linux_test.go b/adapter/http/system_linux_test.go new file mode 100644 index 0000000..7e95685 --- /dev/null +++ b/adapter/http/system_linux_test.go @@ -0,0 +1,420 @@ +//go:build linux + +package httpadapter + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + apiv1 "github.com/valentinkolb/filegate/api/v1" + "github.com/valentinkolb/filegate/domain" + "github.com/valentinkolb/filegate/infra/detect" +) + +func decodeJSON[T any](t *testing.T, r http.Handler, target string) (T, int) { + t.Helper() + + w := httptest.NewRecorder() + r.ServeHTTP(w, authedRequest(http.MethodGet, target)) + + var out T + if w.Result().StatusCode == http.StatusOK || w.Result().StatusCode == http.StatusServiceUnavailable { + if err := json.NewDecoder(w.Result().Body).Decode(&out); err != nil { + t.Fatalf("decode %s: %v", target, err) + } + } + return out, w.Result().StatusCode +} + +func TestSystemInfoReportsBuildMountsAndLimits(t *testing.T) { + base := t.TempDir() + opts := RouterOptions{ + BuildVersion: "1.2.3", + BuildCommit: "abc1234", + BasePaths: []string{base}, + PathCacheSize: 4096, + MaxUploadBytes: 1 << 20, + VersioningEnabled: true, + VersioningMode: "on", + VersioningCooldown: 15 * time.Minute, + VersioningMaxPinnedPerFile: 100, + DetectorStats: func() detect.Stats { + return detect.Stats{Backend: "poll", Interval: 3 * time.Second} + }, + } + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + info, status := decodeJSON[apiv1.SystemInfoResponse](t, r, "/v1/system/info") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + + if info.Build.Version != "1.2.3" || info.Build.Commit != "abc1234" { + t.Errorf("build = %+v, want version 1.2.3 commit abc1234", info.Build) + } + if info.Build.Go == "" { + t.Error("build.go is empty") + } + if info.Detector.Backend != "poll" || info.Detector.IntervalMs != 3000 { + t.Errorf("detector = %+v, want poll at 3000ms", info.Detector) + } + if !info.Versioning.Enabled || info.Versioning.Mode != "on" { + t.Errorf("versioning = %+v, want enabled in mode on", info.Versioning) + } + if info.Limits.PathCacheCapacity != 4096 { + t.Errorf("pathCacheCapacity = %d, want 4096", info.Limits.PathCacheCapacity) + } + if len(info.Mounts) != 1 { + t.Fatalf("mounts = %d, want 1", len(info.Mounts)) + } + mount := info.Mounts[0] + if !mount.Exists || !mount.Writable { + t.Errorf("mount = %+v, want an existing writable mount", mount) + } + if mount.Path != base { + t.Errorf("mount path = %q, want %q", mount.Path, base) + } + if info.UptimeMs < 0 { + t.Errorf("uptime = %d, want >= 0", info.UptimeMs) + } +} + +func TestSystemInfoWithoutDetectorReportsUnknownBackend(t *testing.T) { + // The router must stay usable when the caller supplies no detector hook, + // which is how every existing test constructs it. + r, _, cleanup := newTestRouter(t) + defer cleanup() + + info, status := decodeJSON[apiv1.SystemInfoResponse](t, r, "/v1/system/info") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + if info.Detector.Backend != "unknown" { + t.Errorf("detector backend = %q, want unknown", info.Detector.Backend) + } +} + +func TestSystemRuntimeReportsDetectorAndPools(t *testing.T) { + lastScan := time.Now().Add(-2 * time.Second) + opts := RouterOptions{ + DetectorStats: func() detect.Stats { + return detect.Stats{ + Backend: "btrfs", + Interval: 2 * time.Second, + Cycles: 42, + LastScanAt: lastScan, + Errors: 3, + PendingBatches: 1, + QueueCapacity: 64, + Generations: map[string]uint64{"/data": 99}, + } + }, + } + base := t.TempDir() + r, svc, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + // Touch a path so the cache records at least one lookup. + root := svc.ListRoot()[0] + if _, err := svc.CreateChild(root.ID, "a.txt", false, nil); err != nil { + t.Fatalf("create child: %v", err) + } + + rt, status := decodeJSON[apiv1.SystemRuntimeResponse](t, r, "/v1/system/runtime") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + + if rt.Detector.Backend != "btrfs" || rt.Detector.Cycles != 42 || rt.Detector.Errors != 3 { + t.Errorf("detector = %+v, want btrfs with 42 cycles and 3 errors", rt.Detector) + } + if rt.Detector.Generations["/data"] != 99 { + t.Errorf("generations = %v, want /data at 99", rt.Detector.Generations) + } + if rt.Detector.StaleForMs < 1000 { + t.Errorf("staleForMs = %d, want at least the ~2s since the last scan", rt.Detector.StaleForMs) + } + if rt.Jobs.QueueCapacity <= 0 { + t.Errorf("jobs queue capacity = %d, want > 0", rt.Jobs.QueueCapacity) + } + if rt.PathCache.Capacity <= 0 { + t.Errorf("path cache capacity = %d, want > 0", rt.PathCache.Capacity) + } + if rt.UploadSessions.WriteSlotsLimit <= 0 { + t.Errorf("write slot limit = %d, want > 0", rt.UploadSessions.WriteSlotsLimit) + } +} + +func TestHealthReportsDependencies(t *testing.T) { + base := t.TempDir() + opts := RouterOptions{ + BasePaths: []string{base}, + DetectorStats: func() detect.Stats { + return detect.Stats{Backend: "poll", Interval: 3 * time.Second, LastScanAt: time.Now()} + }, + } + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + health, status := decodeJSON[apiv1.HealthResponse](t, r, "/v1/health") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + if health.Status != apiv1.HealthOK { + t.Errorf("status = %q, want ok; checks=%+v", health.Status, health.Checks) + } + if len(health.Checks) != 3 { + t.Fatalf("checks = %d, want index, detector and mounts", len(health.Checks)) + } +} + +func TestHealthDegradesWhenDetectorStalls(t *testing.T) { + // A detector goroutine that died is the failure this endpoint exists for: + // writes made outside the API silently stop being indexed. + base := t.TempDir() + opts := RouterOptions{ + BasePaths: []string{base}, + DetectorStats: func() detect.Stats { + return detect.Stats{ + Backend: "poll", + Interval: time.Second, + LastScanAt: time.Now().Add(-time.Hour), + } + }, + } + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + health, status := decodeJSON[apiv1.HealthResponse](t, r, "/v1/health") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200 for degraded", status) + } + if health.Status != apiv1.HealthDegraded { + t.Fatalf("status = %q, want degraded; checks=%+v", health.Status, health.Checks) + } + + var detector *apiv1.HealthCheck + for i := range health.Checks { + if health.Checks[i].Name == "detector" { + detector = &health.Checks[i] + } + } + if detector == nil || detector.Status != apiv1.HealthDegraded { + t.Fatalf("detector check = %+v, want degraded", detector) + } + if detector.Detail == "" { + t.Error("degraded detector check has no detail explaining the staleness") + } +} + +func TestHealthFailsWhenMountIsGone(t *testing.T) { + base := t.TempDir() + opts := RouterOptions{BasePaths: []string{base, base + "-does-not-exist"}} + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + health, status := decodeJSON[apiv1.HealthResponse](t, r, "/v1/health") + if status != http.StatusServiceUnavailable { + t.Fatalf("status=%d, want 503 when a mount is unreachable", status) + } + if health.Status != apiv1.HealthFail { + t.Errorf("status = %q, want fail", health.Status) + } +} + +func TestPlainHealthEndpointIsUnchanged(t *testing.T) { + // Existing liveness probes point at GET /health and must keep working. + r, _, cleanup := newTestRouter(t) + defer cleanup() + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + if w.Result().StatusCode != http.StatusOK { + t.Fatalf("status=%d, want 200", w.Result().StatusCode) + } + if got := w.Body.String(); got != "OK" { + t.Errorf("body = %q, want OK", got) + } +} + +func TestListUploadSessionsSurfacesOrphans(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + session := domain.UploadSession{ + ID: "session-orphan", + Path: root.Name + "/big.bin", + ParentID: root.ID, + Filename: "big.bin", + Size: 4096, + SegmentSize: 1024, + TotalSegments: 4, + Phase: domain.UploadSessionInProgress, + CreatedAt: time.Now().Add(-time.Hour).UnixMilli(), + UpdatedAt: time.Now().Add(-time.Hour).UnixMilli(), + } + if err := svc.CreateUploadSession(session); err != nil { + t.Fatalf("create session: %v", err) + } + + list, status := decodeJSON[apiv1.UploadSessionListResponse](t, r, "/v1/uploads/sessions") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + if list.Total != 1 || len(list.Items) != 1 { + t.Fatalf("total=%d items=%d, want exactly the one orphan", list.Total, len(list.Items)) + } + + item := list.Items[0] + if item.ID != "session-orphan" { + t.Errorf("id = %q, want session-orphan", item.ID) + } + if item.Phase != string(domain.UploadSessionInProgress) { + t.Errorf("phase = %q, want in_progress", item.Phase) + } + if item.AgeMs < int64(time.Minute/time.Millisecond) { + t.Errorf("ageMs = %d, want roughly an hour", item.AgeMs) + } + if item.TotalSegments != 4 || item.UploadedSegments != 0 { + t.Errorf("segments = %d/%d, want 0 of 4", item.UploadedSegments, item.TotalSegments) + } +} + +func TestListUploadSessionsFiltersByPhase(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + for id, phase := range map[string]domain.UploadSessionPhase{ + "live": domain.UploadSessionInProgress, + "stopped": domain.UploadSessionAborted, + } { + if err := svc.CreateUploadSession(domain.UploadSession{ + ID: id, Path: root.Name + "/" + id, ParentID: root.ID, Filename: id, + Phase: phase, CreatedAt: time.Now().UnixMilli(), + }); err != nil { + t.Fatalf("create %s: %v", id, err) + } + } + + list, status := decodeJSON[apiv1.UploadSessionListResponse](t, r, "/v1/uploads/sessions?phase=aborted") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + if list.Total != 1 || list.Items[0].ID != "stopped" { + t.Fatalf("got %+v, want only the aborted session", list.Items) + } + + w := httptest.NewRecorder() + r.ServeHTTP(w, authedRequest(http.MethodGet, "/v1/uploads/sessions?phase=nonsense")) + if w.Result().StatusCode != http.StatusBadRequest { + t.Errorf("unknown phase status=%d, want 400", w.Result().StatusCode) + } +} + +func TestSystemEndpointsRequireAuth(t *testing.T) { + r, _, cleanup := newTestRouter(t) + defer cleanup() + + for _, target := range []string{"/v1/system/info", "/v1/system/runtime", "/v1/health", "/v1/uploads/sessions"} { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + if w.Result().StatusCode != http.StatusUnauthorized { + t.Errorf("%s without a token: status=%d, want 401", target, w.Result().StatusCode) + } + } +} + +// An oversized body is a client error. Before this mapping only the +// direct-upload path translated it, so a plain PUT answered 500 and gave the +// caller nothing to act on. +func TestOversizedUploadAnswers413(t *testing.T) { + base := t.TempDir() + r, svc, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, RouterOptions{MaxUploadBytes: 16}) + defer cleanup() + + root := svc.ListRoot()[0] + body := strings.Repeat("x", 1024) + + req := authedRequest(http.MethodPut, "/v1/paths/"+root.Name+"/big.txt") + req.Body = io.NopCloser(strings.NewReader(body)) + req.Header.Set("Content-Type", "application/octet-stream") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if got := w.Result().StatusCode; got != http.StatusRequestEntityTooLarge { + t.Fatalf("status = %d, want 413", got) + } +} + +func TestManualPruneReportsWhatItDid(t *testing.T) { + var calls int + opts := RouterOptions{ + PruneNow: func() (domain.PruneStats, error) { + calls++ + return domain.PruneStats{FilesScanned: 12, VersionsKept: 30, VersionsDeleted: 4, OrphansPurged: 1, BlobsDeleted: 5}, nil + }, + } + base := t.TempDir() + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + w := httptest.NewRecorder() + r.ServeHTTP(w, authedRequest(http.MethodPost, "/v1/versions/prune")) + if w.Result().StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Result().StatusCode) + } + + var out apiv1.PruneResponse + if err := json.NewDecoder(w.Result().Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + // All six fields, not the three that reach Prometheus: orphans and blobs + // are what say whether space was actually reclaimed. + if out.FilesScanned != 12 || out.VersionsDeleted != 4 || out.OrphansPurged != 1 || out.BlobsDeleted != 5 { + t.Errorf("stats = %+v, want the full result", out) + } + if calls != 1 { + t.Errorf("prune called %d times, want 1", calls) + } +} + +// A round already in flight must be refused, not queued behind the first: two +// overlapping scans duplicate the work and report halves of it separately. +func TestManualPruneRefusesWhenAlreadyRunning(t *testing.T) { + opts := RouterOptions{ + PruneNow: func() (domain.PruneStats, error) { + return domain.PruneStats{}, errors.New("a pruning round is already in progress") + }, + } + base := t.TempDir() + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + w := httptest.NewRecorder() + r.ServeHTTP(w, authedRequest(http.MethodPost, "/v1/versions/prune")) + if w.Result().StatusCode != http.StatusConflict { + t.Fatalf("status = %d, want 409", w.Result().StatusCode) + } +} + +// Without versioning there is nothing to prune, and saying so beats pretending +// a round ran and found nothing. +func TestManualPruneUnavailableWithoutTheHook(t *testing.T) { + r, _, cleanup := newTestRouter(t) + defer cleanup() + + w := httptest.NewRecorder() + r.ServeHTTP(w, authedRequest(http.MethodPost, "/v1/versions/prune")) + if w.Result().StatusCode != http.StatusNotImplemented { + t.Fatalf("status = %d, want 501", w.Result().StatusCode) + } +} diff --git a/adapter/http/thumbnail.go b/adapter/http/thumbnail.go index 80e4ca6..b56ae9f 100644 --- a/adapter/http/thumbnail.go +++ b/adapter/http/thumbnail.go @@ -255,3 +255,19 @@ func (t *thumbnailer) generateOne(absPath string, size int, mtime int64) (thumbn } return item, nil } + +// schedulerStats exposes thumbnail worker-pool pressure for /v1/system/runtime. +func (t *thumbnailer) schedulerStats() jobs.Stats { + if t == nil { + return jobs.Stats{} + } + return t.scheduler.Stats() +} + +// cacheStats exposes thumbnail cache occupancy and effectiveness. +func (t *thumbnailer) cacheStats() cache.Stats { + if t == nil { + return cache.Stats{} + } + return t.cache.Stats() +} diff --git a/adapter/http/upload_sessions.go b/adapter/http/upload_sessions.go index 508c698..ed9150c 100644 --- a/adapter/http/upload_sessions.go +++ b/adapter/http/upload_sessions.go @@ -14,7 +14,6 @@ import ( "io" "log" "net/http" - "net/netip" "os" "path/filepath" "regexp" @@ -51,14 +50,10 @@ var ( type uploadSessionManager struct { svc *domain.Service - secret []byte - publicURL string - trusted []netip.Prefix + secret []byte + live liveConfig - maxSegmentBytes int64 - maxUploadBytes int64 maxWrites int - minFreeBytes int64 expiry time.Duration cleanupInterval time.Duration @@ -81,19 +76,11 @@ type uploadSessionToken struct { func newUploadSessionManager( svc *domain.Service, - bearerToken, publicURL string, - maxSegmentBytes, maxUploadBytes int64, + bearerToken string, + live liveConfig, maxConcurrentWrites int, - minFreeBytes int64, expiry, cleanupInterval time.Duration, - trusted []netip.Prefix, ) *uploadSessionManager { - if maxSegmentBytes <= 0 { - maxSegmentBytes = 50 << 20 - } - if maxUploadBytes <= 0 { - maxUploadBytes = 50 << 30 - } if maxConcurrentWrites <= 0 { maxConcurrentWrites = runtime.NumCPU() * 8 if maxConcurrentWrites < 32 { @@ -103,18 +90,11 @@ func newUploadSessionManager( maxConcurrentWrites = 512 } } - if minFreeBytes < 0 { - minFreeBytes = 0 - } m := &uploadSessionManager{ svc: svc, secret: []byte(strings.TrimSpace(bearerToken)), - publicURL: strings.TrimRight(strings.TrimSpace(publicURL), "/"), - trusted: append([]netip.Prefix(nil), trusted...), - maxSegmentBytes: maxSegmentBytes, - maxUploadBytes: maxUploadBytes, + live: live, maxWrites: maxConcurrentWrites, - minFreeBytes: minFreeBytes, expiry: expiry, cleanupInterval: cleanupInterval, locks: xsync.NewMap[string, *sync.Mutex](), @@ -242,31 +222,33 @@ func (m *uploadSessionManager) cleanupExpired() error { return nil } +// removeSessionArtifacts deletes a session's staged segments and its assembled +// file. +// +// The paths are derived rather than looked up or globbed. A segment file only +// ever lives at segmentPath(session, i) for an index the PUT handler validated +// into [0, TotalSegments), and the partial writes it makes carry a +// .upload-segment-* name that never matched the old glob anyway. Deriving them +// drops an index read and, more importantly, a directory scan: the stage +// directory is shared by every session on the mount, so globbing it once per +// commit turned a five-thousand-file upload into five thousand scans of a +// five-thousand-entry directory. +// +// The directories are deliberately not fsynced. The only thing that would +// guarantee is that the deletion of temporary staging files survives a crash, +// and a resurrected staging file is harmless: a committed session answers from +// its commit record without consulting segments, an aborted one is closed to +// writes, and the cleanup loop sweeps whatever is left. Two directory fsyncs per +// commit is a real cost paid for keeping garbage deleted. func (m *uploadSessionManager) removeSessionArtifacts(session domain.UploadSession) error { - segments, _ := m.svc.ListUploadSegments(session.ID) - for _, segment := range segments { - _ = os.Remove(segment.Path) - } - if session.StageDir != "" { - for _, path := range orphanSegmentPaths(session) { - _ = os.Remove(path) - } - _ = os.Remove(filepath.Join(filepath.Dir(session.StageDir), uploadSessionCompleteSubdir, session.ID+".complete")) - _ = filesystem.SyncDir(session.StageDir) - _ = filesystem.SyncDir(filepath.Join(filepath.Dir(session.StageDir), uploadSessionCompleteSubdir)) - } - return nil -} - -func orphanSegmentPaths(session domain.UploadSession) []string { if session.StageDir == "" || session.ID == "" { return nil } - paths, err := filepath.Glob(filepath.Join(session.StageDir, session.ID+"-*.part")) - if err != nil { - return nil + for i := 0; i < session.TotalSegments; i++ { + _ = os.Remove(segmentPath(session, i)) } - return paths + _ = os.Remove(filepath.Join(filepath.Dir(session.StageDir), uploadSessionCompleteSubdir, session.ID+".complete")) + return nil } func generateUploadSessionID() (string, error) { @@ -444,8 +426,8 @@ func (m *uploadSessionManager) directForRequest(r *http.Request, sessionID strin } func (m *uploadSessionManager) baseURLForRequest(r *http.Request) (string, error) { - if m.publicURL != "" { - return m.publicURL, nil + if publicURL := m.live.publicURL(); publicURL != "" { + return publicURL, nil } host := r.Host proto := "" @@ -469,7 +451,7 @@ func (m *uploadSessionManager) baseURLForRequest(r *http.Request) (string, error } func (m *uploadSessionManager) peerTrusted(remoteAddr string) bool { - return peerTrusted(remoteAddr, m.trusted) + return peerTrusted(remoteAddr, m.live.trustedProxies()) } func cleanSessionUploadPath(raw string) (string, error) { @@ -581,7 +563,8 @@ func (m *uploadSessionManager) createSession(r *http.Request, body apiv1.UploadS if err != nil { return apiv1.UploadSessionResponse{}, err } - if body.Size <= 0 || body.Size > m.maxUploadBytes { + maxUploadBytes := m.live.maxSessionUploadBytes() + if body.Size <= 0 || body.Size > maxUploadBytes { return apiv1.UploadSessionResponse{}, domain.ErrInvalidArgument } if !checksumRE.MatchString(strings.TrimSpace(body.Checksum)) { @@ -591,10 +574,11 @@ func (m *uploadSessionManager) createSession(r *http.Request, body apiv1.UploadS if segmentSize <= 0 { segmentSize = fallbackSegmentSize } + maxSegmentBytes := m.live.maxChunkBytes() if segmentSize <= 0 { - segmentSize = m.maxSegmentBytes + segmentSize = maxSegmentBytes } - if segmentSize <= 0 || segmentSize > m.maxSegmentBytes { + if segmentSize <= 0 || segmentSize > maxSegmentBytes { return apiv1.UploadSessionResponse{}, domain.ErrInvalidArgument } mode, err := domain.ParseConflictMode(body.OnConflict, domain.FileConflictModes) @@ -671,8 +655,8 @@ func (m *uploadSessionManager) ensureSpace(stageRoot string, bytesNeeded int64) return err } needed := uint64(bytesNeeded) - if m.minFreeBytes > 0 { - needed += uint64(m.minFreeBytes) + if minFreeBytes := m.live.uploadMinFreeBytes(); minFreeBytes > 0 { + needed += uint64(minFreeBytes) } if free < needed { return domain.ErrInsufficientStorage @@ -1089,8 +1073,20 @@ func (m *uploadSessionManager) handleCommit(w http.ResponseWriter, r *http.Reque return } completePath := filepath.Join(completeDir, session.ID+".complete") - if err := assembleUploadSession(*session, byIndex, completePath); err != nil { - statusFromErr(w, err) + // An earlier commit attempt may have assembled the file and then failed + // further along -- on a conflict at the destination, say. Reassembling is + // not merely wasted work here: the single-segment path moves the staged + // segment into place, so the input no longer exists. A recorded segment is + // immutable (re-uploading different bytes is refused with a conflict), so + // an assembled file can only hold the bytes the session declared, and the + // checksum below verifies it either way. + if _, statErr := os.Stat(completePath); errors.Is(statErr, os.ErrNotExist) { + if err := assembleUploadSession(*session, byIndex, completePath); err != nil { + statusFromErr(w, err) + return + } + } else if statErr != nil { + statusFromErr(w, statErr) return } hashes, size, err := hashWholeFile(completePath) @@ -1206,31 +1202,70 @@ func (m *uploadSessionManager) ensureSessionParent(session domain.UploadSession) if len(parts) <= 2 { return session.ParentID, noop, nil } + + // Almost every commit lands in a directory that already exists, because an + // earlier file in the same upload created it. One lookup settles that. The + // loop below reaches the same answer by re-creating every level in turn, and + // each of those levels costs a path lock, a filesystem walk of its own + // prefix and an index read -- work that scales with tree depth and was being + // paid once per file. Nothing is created here, so the rollback stays a + // no-op, which is what it should be for a directory this commit found. + parentPath := strings.Join(parts[:len(parts)-1], "/") + if id, err := m.svc.ResolvePath(parentPath); err == nil { + return id, noop, nil + } else if !errors.Is(err, domain.ErrNotFound) { + return domain.FileID{}, noop, err + } + root, _, err := m.mountRootByName(parts[0]) if err != nil { return domain.FileID{}, noop, err } parentRelParts := parts[1 : len(parts)-1] - created := make([]domain.FileID, 0, len(parentRelParts)) - var parentID domain.FileID + + // Note which levels are absent before creating anything, so the rollback + // only ever removes directories this commit introduced. These are reads; + // the mkdir below is the single write. + missing := make([]string, 0, len(parentRelParts)) for i := range parentRelParts { - rel := strings.Join(parentRelParts[:i+1], "/") - virtualPath := parts[0] + "/" + rel - _, existedErr := m.svc.ResolvePath(virtualPath) - meta, err := m.svc.MkdirRelative(root.ID, rel, true, nil, domain.ConflictSkip) - if err != nil { - rollbackEmptyDirs(m.svc, created) + levelPath := parts[0] + "/" + strings.Join(parentRelParts[:i+1], "/") + if _, err := m.svc.ResolvePath(levelPath); errors.Is(err, domain.ErrNotFound) { + missing = append(missing, levelPath) + } else if err != nil { return domain.FileID{}, noop, err } - if errors.Is(existedErr, domain.ErrNotFound) { - created = append(created, meta.ID) - } else if existedErr != nil { - rollbackEmptyDirs(m.svc, created) - return domain.FileID{}, noop, existedErr + } + + // One recursive mkdir for the whole chain. Calling it once per level made + // the same directories, but each call re-acquired a path lock and re-walked + // its own prefix, so a chain of depth d cost d locks and d prefix walks + // instead of one -- the dominant cost of committing into a deep tree. + // Levels that were absent and now exist belong to this commit, and that has + // to be evaluated on the failure path too: a recursive mkdir can create a + // prefix and then stop at a level where a file sits where a directory + // belongs, leaving those directories behind with nobody to remove them. + // A level that still does not resolve is simply left out; the worst outcome + // is an empty directory nobody cleans up. + createdIDs := func() []domain.FileID { + out := make([]domain.FileID, 0, len(missing)) + for _, levelPath := range missing { + id, err := m.svc.ResolvePath(levelPath) + if err != nil { + continue + } + out = append(out, id) } - parentID = meta.ID + return out } - return parentID, func() { rollbackEmptyDirs(m.svc, created) }, nil + + meta, err := m.svc.MkdirRelative(root.ID, strings.Join(parentRelParts, "/"), true, nil, domain.ConflictSkip) + if err != nil { + rollbackEmptyDirs(m.svc, createdIDs()) + return domain.FileID{}, noop, err + } + + created := createdIDs() + return meta.ID, func() { rollbackEmptyDirs(m.svc, created) }, nil } func rollbackEmptyDirs(svc *domain.Service, ids []domain.FileID) { @@ -1247,7 +1282,52 @@ func rollbackEmptyDirs(svc *domain.Service, ids []domain.FileID) { } } +// adoptSingleSegment moves a lone staged segment into place instead of copying +// it, reporting whether the move was used. +// +// Most small-file uploads are exactly one segment, and for those the copy was +// the entire cost of commit: the segment is read back, written out a second +// time, then read a third time to hash. A rename produces the same file for one +// directory update. The staged segment and the complete directory are siblings +// under the same session root, so the rename stays within one filesystem. +// +// The per-segment checksum comparison the copy loop performs is not lost. The +// caller hashes the assembled file and rejects the commit unless the size and +// SHA-256 match the session, and with one segment those are the same bytes the +// loop would have covered. +// +// A false return means fall back to copying. Rename can fail for reasons this +// code should not have to enumerate -- a segment staged on another device, a +// filesystem that refuses the operation -- and the copy path reports the real +// error if the input is genuinely unusable. +func adoptSingleSegment(session domain.UploadSession, segments map[int]domain.UploadSegment, completePath string) (bool, error) { + segment, ok := segments[0] + if !ok { + return false, domain.ErrInvalidArgument + } + if segment.Size != session.Size { + return false, fmt.Errorf("segment size mismatch") + } + if err := os.Rename(segment.Path, completePath); err != nil { + return false, nil + } + if err := filesystem.SyncDir(filepath.Dir(completePath)); err != nil { + return false, err + } + return true, nil +} + func assembleUploadSession(session domain.UploadSession, segments map[int]domain.UploadSegment, completePath string) error { + if session.TotalSegments == 1 { + moved, err := adoptSingleSegment(session, segments, completePath) + if err != nil { + return err + } + if moved { + return nil + } + } + tmp := completePath + ".tmp" out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { @@ -1336,3 +1416,19 @@ func (m *uploadSessionManager) handleAbort(w http.ResponseWriter, r *http.Reques } w.WriteHeader(http.StatusNoContent) } + +// writeSlotsInUse reports how many concurrent segment-write slots are held. The +// limit is already published via /v1/capabilities; this is the usage side of it. +func (m *uploadSessionManager) writeSlotsInUse() int { + if m == nil { + return 0 + } + return len(m.writeSlots) +} + +func (m *uploadSessionManager) writeSlotsLimit() int { + if m == nil { + return 0 + } + return cap(m.writeSlots) +} diff --git a/adapter/http/upload_sessions_linux_test.go b/adapter/http/upload_sessions_linux_test.go index cb3da08..cd905fe 100644 --- a/adapter/http/upload_sessions_linux_test.go +++ b/adapter/http/upload_sessions_linux_test.go @@ -626,3 +626,185 @@ func TestUploadSessionDirectTokenCanPutAndCommit(t *testing.T) { t.Fatalf("resolve direct upload: %v", err) } } + +// A session token is a capability for exactly one session. Nothing else in the +// suite pinned that, so a scoping regression would have shipped silently. +func TestUploadSessionTokenIsScopedToOneSession(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + content := []byte("scoped token payload") + victim := createUploadSession(t, r, root.Name+"/scoped/victim.txt", content, int64(len(content)), true) + attacker := createUploadSession(t, r, root.Name+"/scoped/attacker.txt", content, int64(len(content)), true) + if victim.Direct == nil || attacker.Direct == nil { + t.Fatalf("expected direct tokens on both sessions") + } + + cases := []struct { + name string + method string + target string + body io.Reader + }{ + {"status", http.MethodGet, "/v1/uploads/sessions/" + victim.ID, nil}, + {"putSegment", http.MethodPut, "/v1/uploads/sessions/" + victim.ID + "/segments/0", bytes.NewReader(content)}, + {"commit", http.MethodPost, "/v1/uploads/sessions/" + victim.ID + "/commit", nil}, + {"abort", http.MethodDelete, "/v1/uploads/sessions/" + victim.ID, nil}, + } + for _, tc := range cases { + w := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.target, tc.body) + req.Header.Set("Filegate-Upload-Session", attacker.Direct.Token) + r.ServeHTTP(w, req) + if w.Result().StatusCode != http.StatusUnauthorized { + t.Errorf("%s with another session's token: status=%d, want 401", tc.name, w.Result().StatusCode) + } + } + + // The victim session must still be usable afterwards. + if got := putSessionSegment(t, r, victim.ID, 0, content); got.Result().StatusCode != http.StatusOK { + t.Fatalf("victim segment put status=%d body=%s", got.Result().StatusCode, got.Body.String()) + } +} + +// Abort is destructive, so it must refuse both anonymous callers and tokens +// that were minted without the abort scope. +func TestUploadSessionAbortRequiresAbortScope(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + content := []byte("abort scope payload") + body := apiv1.UploadSessionCreateRequest{ + Path: root.Name + "/scoped/no-abort.txt", + Size: int64(len(content)), + Checksum: sha256Prefixed(content), + SegmentSize: int64(len(content)), + OnConflict: "error", + Direct: &apiv1.UploadSessionDirectRequest{ + ExpiresInSeconds: 60, + Allow: []string{"putSegment", "status"}, + }, + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, authedJSONRequest(http.MethodPost, "/v1/uploads/sessions", raw)) + if w.Result().StatusCode != http.StatusCreated { + t.Fatalf("create status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + var session apiv1.UploadSessionResponse + if err := json.NewDecoder(w.Result().Body).Decode(&session); err != nil { + t.Fatalf("decode: %v", err) + } + + anonymous := httptest.NewRecorder() + r.ServeHTTP(anonymous, httptest.NewRequest(http.MethodDelete, "/v1/uploads/sessions/"+session.ID, nil)) + if anonymous.Result().StatusCode != http.StatusUnauthorized { + t.Errorf("anonymous abort status=%d, want 401", anonymous.Result().StatusCode) + } + + scoped := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/v1/uploads/sessions/"+session.ID, nil) + req.Header.Set("Filegate-Upload-Session", session.Direct.Token) + r.ServeHTTP(scoped, req) + if scoped.Result().StatusCode != http.StatusUnauthorized { + t.Errorf("abort without the abort scope status=%d, want 401", scoped.Result().StatusCode) + } + + stored, err := svc.LookupUploadSession(session.ID) + if err != nil { + t.Fatalf("lookup: %v", err) + } + if stored.Phase != domain.UploadSessionInProgress { + t.Fatalf("phase=%q, want the session untouched", stored.Phase) + } +} + +// Aborting frees the staged bytes but leaves an aborted row behind, so a late +// segment PUT cannot resurrect a cancelled upload. +func TestUploadSessionAbortFreesBytesAndKeepsTheRow(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + content := []byte("abort payload") + session := createUploadSession(t, r, root.Name+"/gc/aborted.txt", content, int64(len(content)), false) + if got := putSessionSegment(t, r, session.ID, 0, content); got.Result().StatusCode != http.StatusOK { + t.Fatalf("segment put status=%d", got.Result().StatusCode) + } + segs, err := svc.ListUploadSegments(session.ID) + if err != nil || len(segs) != 1 { + t.Fatalf("segments=%d err=%v", len(segs), err) + } + + abort := httptest.NewRecorder() + r.ServeHTTP(abort, authedJSONRequest(http.MethodDelete, "/v1/uploads/sessions/"+session.ID, nil)) + if abort.Result().StatusCode != http.StatusNoContent { + t.Fatalf("abort status=%d body=%s", abort.Result().StatusCode, abort.Body.String()) + } + + stored, err := svc.LookupUploadSession(session.ID) + if err != nil { + t.Fatalf("lookup after abort: %v", err) + } + if stored.Phase != domain.UploadSessionAborted { + t.Fatalf("phase=%q, want aborted", stored.Phase) + } + if _, statErr := os.Stat(segs[0].Path); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("segment file %s still present after abort", segs[0].Path) + } + late := putSessionSegment(t, r, session.ID, 0, content) + if late.Result().StatusCode != http.StatusConflict { + t.Fatalf("segment PUT after abort status=%d, want 409", late.Result().StatusCode) + } +} + +// The expiry sweep is the other half of GC: without it the aborted rows above +// would accumulate in the index forever. +func TestUploadSessionSweepRemovesExpiredRows(t *testing.T) { + r, svc, cleanup := newTestRouterWithCustomLimits(t, t.TempDir(), t.TempDir(), RouterOptions{ + BearerToken: "test-token", + JobWorkers: 2, + JobQueueSize: 64, + UploadExpiry: 10 * time.Millisecond, + UploadCleanupInterval: 20 * time.Millisecond, + MaxChunkBytes: 10 << 20, + MaxSessionUploadBytes: 10 << 20, + MaxUploadBytes: 10 << 20, + }) + defer cleanup() + _ = r + + root := svc.ListRoot()[0] + stale := domain.UploadSession{ + ID: "upl_" + strings.Repeat("a", 32), + Path: root.Name + "/gc/stale.bin", + ParentID: root.ID, + Filename: "stale.bin", + Size: 1024, + SegmentSize: 1024, + TotalSegments: 1, + Phase: domain.UploadSessionAborted, + CreatedAt: time.Now().Add(-time.Hour).UnixMilli(), + UpdatedAt: time.Now().Add(-time.Hour).UnixMilli(), + } + if err := svc.CreateUploadSession(stale); err != nil { + t.Fatalf("create stale session: %v", err) + } + + deadline := time.Now().Add(5 * time.Second) + for { + _, err := svc.LookupUploadSession(stale.ID) + if errors.Is(err, domain.ErrNotFound) { + return + } + if time.Now().After(deadline) { + t.Fatalf("stale session still present after the sweep window: %v", err) + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/adapter/http/upload_sessions_parent_linux_test.go b/adapter/http/upload_sessions_parent_linux_test.go new file mode 100644 index 0000000..443284b --- /dev/null +++ b/adapter/http/upload_sessions_parent_linux_test.go @@ -0,0 +1,236 @@ +//go:build linux + +package httpadapter + +import ( + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/valentinkolb/filegate/domain" +) + +// Committing into a deep path creates the whole chain and resolves it. +// +// The chain is created by one recursive mkdir rather than one call per level. +// Calling per level produced the same directories but re-acquired a path lock +// and re-walked its own prefix each time, which is the dominant cost of +// committing into a deep tree. +func TestCommitCreatesDeepParentChain(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + content := []byte("deep") + path := root.Name + "/a/b/c/d/e/deep.txt" + session := createUploadSession(t, r, path, content, int64(len(content)), false) + + if w := putSessionSegment(t, r, session.ID, 0, content); w.Result().StatusCode != http.StatusOK { + t.Fatalf("put segment status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + if w := commitSession(t, r, session.ID); w.Result().StatusCode != http.StatusOK { + t.Fatalf("commit status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + + // Every level has to be resolvable, not merely present on disk: the index + // is what the API answers from. + for _, rel := range []string{"a", "a/b", "a/b/c", "a/b/c/d", "a/b/c/d/e", "a/b/c/d/e/deep.txt"} { + if _, err := svc.ResolvePath(root.Name + "/" + rel); err != nil { + t.Errorf("%s does not resolve after commit: %v", rel, err) + } + } +} + +// The rollback list holds exactly the levels this commit created. +// +// This exercises ensureSessionParent directly because the interesting failure is +// not reachable through the HTTP surface: a file standing where a directory +// belongs can only exist if every level above it already does, so the recursive +// mkdir fails with nothing created. The list still has to be right, because the +// publish step after it can fail for its own reasons and that closure is what +// runs. +func TestParentRollbackListCoversOnlyNewLevels(t *testing.T) { + _, svc, cleanup := newTestRouter(t) + defer cleanup() + + manager := newUploadSessionManager(svc, "test-token", newLiveConfig(RouterOptions{ + MaxChunkBytes: 1 << 20, + MaxSessionUploadBytes: 1 << 30, + }), 4, time.Hour, 0) + defer manager.Close() + + root := svc.ListRoot()[0] + rootAbs, err := svc.ResolveAbsPath(root.ID) + if err != nil { + t.Fatalf("resolve root: %v", err) + } + + // "existing" predates the call and holds a file, so it is not this commit's + // to undo. Everything below it is new. + if err := os.MkdirAll(filepath.Join(rootAbs, "existing"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(rootAbs, "existing", "resident.txt"), []byte("stay"), 0o644); err != nil { + t.Fatalf("write resident: %v", err) + } + + session := domain.UploadSession{ + Path: root.Name + "/existing/fresh/deeper/file.txt", + ParentID: root.ID, + } + parentID, rollback, err := manager.ensureSessionParent(session) + if err != nil { + t.Fatalf("ensureSessionParent: %v", err) + } + if parentID.IsZero() { + t.Fatal("ensureSessionParent returned a zero parent id") + } + for _, rel := range []string{"existing/fresh", "existing/fresh/deeper"} { + if _, err := svc.ResolvePath(root.Name + "/" + rel); err != nil { + t.Fatalf("%s was not created: %v", rel, err) + } + } + + rollback() + + // The two new levels are empty, so the rollback takes them. + for _, rel := range []string{"existing/fresh/deeper", "existing/fresh"} { + if _, err := os.Lstat(filepath.Join(rootAbs, rel)); !os.IsNotExist(err) { + t.Errorf("%s survived the rollback: %v", rel, err) + } + } + // The pre-existing level and its content are untouched. + if _, err := os.Lstat(filepath.Join(rootAbs, "existing", "resident.txt")); err != nil { + t.Errorf("pre-existing content was removed: %v", err) + } +} + +// A file standing where a directory belongs fails the commit and survives it. +func TestCommitThroughAFileFailsWithoutDamagingIt(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + rootAbs, err := svc.ResolveAbsPath(root.ID) + if err != nil { + t.Fatalf("resolve root: %v", err) + } + if err := os.MkdirAll(filepath.Join(rootAbs, "keep"), 0o755); err != nil { + t.Fatalf("mkdir keep: %v", err) + } + blocker := filepath.Join(rootAbs, "keep", "blocker") + if err := os.WriteFile(blocker, []byte("not a dir"), 0o644); err != nil { + t.Fatalf("write blocker: %v", err) + } + + content := []byte("never lands") + session := createUploadSession(t, r, root.Name+"/keep/blocker/deeper/file.txt", content, int64(len(content)), false) + if w := putSessionSegment(t, r, session.ID, 0, content); w.Result().StatusCode != http.StatusOK { + t.Fatalf("put segment status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + if commit := commitSession(t, r, session.ID); commit.Result().StatusCode == http.StatusOK { + t.Fatalf("commit succeeded through a file: %s", commit.Body.String()) + } + + got, err := os.ReadFile(blocker) + if err != nil || string(got) != "not a dir" { + t.Errorf("blocker = %q, %v; want it untouched", got, err) + } + if info, err := os.Lstat(blocker); err != nil || info.IsDir() { + t.Errorf("blocker is no longer a plain file: %v", err) + } +} + +// Directories the commit found are not the commit's to remove. +// +// The rollback list is built from the levels that were absent beforehand. Built +// from the resolved chain instead, a failure would delete directories another +// upload was still filling. +func TestFailedCommitKeepsPreExistingEmptyDirectories(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + rootAbs, err := svc.ResolveAbsPath(root.ID) + if err != nil { + t.Fatalf("resolve root: %v", err) + } + + // An empty directory tree that exists before the commit. Empty is the case + // that matters: rollbackEmptyDirs only removes empty directories, so a + // populated one would pass this test for the wrong reason. + preExisting := filepath.Join(rootAbs, "reserved", "slot") + if err := os.MkdirAll(preExisting, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(preExisting, "blocker"), []byte("x"), 0o644); err != nil { + t.Fatalf("write blocker: %v", err) + } + + content := []byte("blocked") + path := root.Name + "/reserved/slot/blocker/file.txt" + session := createUploadSession(t, r, path, content, int64(len(content)), false) + if w := putSessionSegment(t, r, session.ID, 0, content); w.Result().StatusCode != http.StatusOK { + t.Fatalf("put segment status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + if commit := commitSession(t, r, session.ID); commit.Result().StatusCode == http.StatusOK { + t.Fatalf("commit succeeded through a file: %s", commit.Body.String()) + } + + for _, rel := range []string{"reserved", "reserved/slot"} { + if _, err := os.Lstat(filepath.Join(rootAbs, rel)); err != nil { + t.Errorf("%s was removed by a failed commit that did not create it: %v", rel, err) + } + } +} + +// Staged artifacts are removed by deriving their paths, not by scanning the +// stage directory, which every session on the mount shares. A multi-segment +// session is the case that would notice an off-by-one in the derivation. +func TestMultiSegmentCommitRemovesEveryStagedSegment(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + content := []byte("several segments worth of content, split up") + session := createUploadSession(t, r, root.Name+"/multi/file.txt", content, 8, false) + if session.TotalSegments < 3 { + t.Fatalf("want a multi-segment session, got %d", session.TotalSegments) + } + + for _, seg := range session.Segments { + part := content[seg.Offset : seg.Offset+seg.Size] + if w := putSessionSegment(t, r, session.ID, seg.Index, part); w.Result().StatusCode != http.StatusOK { + t.Fatalf("put segment %d status=%d body=%s", seg.Index, w.Result().StatusCode, w.Body.String()) + } + } + + staged, err := svc.ListUploadSegments(session.ID) + if err != nil || len(staged) != session.TotalSegments { + t.Fatalf("list segments: %v (%d of %d)", err, len(staged), session.TotalSegments) + } + + if w := commitSession(t, r, session.ID); w.Result().StatusCode != http.StatusOK { + t.Fatalf("commit status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + + for _, segment := range staged { + if _, err := os.Lstat(segment.Path); !os.IsNotExist(err) { + t.Errorf("staged segment %s survived commit: %v", segment.Path, err) + } + } + + rootAbs, err := svc.ResolveAbsPath(root.ID) + if err != nil { + t.Fatalf("resolve root: %v", err) + } + got, err := os.ReadFile(filepath.Join(rootAbs, "multi", "file.txt")) + if err != nil { + t.Fatalf("read published file: %v", err) + } + if string(got) != string(content) { + t.Fatalf("published content = %q, want %q", got, content) + } +} diff --git a/adapter/http/upload_sessions_single_segment_linux_test.go b/adapter/http/upload_sessions_single_segment_linux_test.go new file mode 100644 index 0000000..3917c3c --- /dev/null +++ b/adapter/http/upload_sessions_single_segment_linux_test.go @@ -0,0 +1,266 @@ +//go:build linux + +package httpadapter + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + apiv1 "github.com/valentinkolb/filegate/api/v1" + "github.com/valentinkolb/filegate/domain" +) + +func inodeOf(t *testing.T, path string) uint64 { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("lstat %s: %v", path, err) + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + t.Skip("inode identity unavailable on this platform") + } + return uint64(st.Ino) +} + +func commitSession(t *testing.T, r http.Handler, sessionID string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, authedJSONRequest(http.MethodPost, "/v1/uploads/sessions/"+sessionID+"/commit", nil)) + return w +} + +// A one-segment commit must move the staged segment rather than copy it. +// +// Commit dominated every upload-session run in the many-small-files benchmark +// -- 470s against 193s of segment PUTs for 5000 log files -- because assembly +// read each staged segment back and wrote the whole file a second time. Most +// small-file uploads are exactly one segment, so the copy was pure overhead. +// +// Identity is the assertion that actually distinguishes the two: a rename keeps +// the inode, a copy cannot. Comparing timings would prove nothing on a warm +// page cache. +func TestSingleSegmentCommitMovesTheSegment(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + content := []byte("one segment, moved rather than copied") + session := createUploadSession(t, r, root.Name+"/single/moved.txt", content, int64(len(content)), false) + if session.TotalSegments != 1 { + t.Fatalf("want a single-segment session, got %d", session.TotalSegments) + } + + if w := putSessionSegment(t, r, session.ID, 0, content); w.Result().StatusCode != http.StatusOK { + t.Fatalf("put segment status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + + staged, err := svc.ListUploadSegments(session.ID) + if err != nil || len(staged) != 1 { + t.Fatalf("list segments: %v (%d segments)", err, len(staged)) + } + stagedInode := inodeOf(t, staged[0].Path) + + if w := commitSession(t, r, session.ID); w.Result().StatusCode != http.StatusOK { + t.Fatalf("commit status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + + rootAbs, err := svc.ResolveAbsPath(root.ID) + if err != nil { + t.Fatalf("resolve root: %v", err) + } + published := filepath.Join(rootAbs, "single", "moved.txt") + + got, err := os.ReadFile(published) + if err != nil { + t.Fatalf("read published file: %v", err) + } + if string(got) != string(content) { + t.Fatalf("published content = %q, want %q", got, content) + } + if published := inodeOf(t, published); published != stagedInode { + t.Errorf("published inode %d differs from staged segment inode %d; the segment was copied, not moved", published, stagedInode) + } + if _, err := os.Lstat(staged[0].Path); !os.IsNotExist(err) { + t.Errorf("staged segment still present after commit: %v", err) + } +} + +// Publication is a single rename, so a crash can never expose a prefix. +// +// The bytes reach their destination by renaming the assembled file over it. +// Rename either happened or did not, which is what makes a crash between +// publishing the bytes and writing the index safe: the destination goes from +// absent to complete in one step, and the index is rebuildable from the +// filesystem. This pins the part that would break silently -- an assembly step +// that wrote into the destination directly, or left a partial file behind. +func TestSingleSegmentCommitLeavesNoPartialFile(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + content := []byte(strings.Repeat("partial-publish-guard;", 4096)) + session := createUploadSession(t, r, root.Name+"/single/atomic.txt", content, int64(len(content)), false) + + if w := putSessionSegment(t, r, session.ID, 0, content); w.Result().StatusCode != http.StatusOK { + t.Fatalf("put segment status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + + rootAbs, err := svc.ResolveAbsPath(root.ID) + if err != nil { + t.Fatalf("resolve root: %v", err) + } + published := filepath.Join(rootAbs, "single", "atomic.txt") + + // Nothing may appear at the destination before commit runs. + if _, err := os.Lstat(published); !os.IsNotExist(err) { + t.Fatalf("destination exists before commit: %v", err) + } + + if w := commitSession(t, r, session.ID); w.Result().StatusCode != http.StatusOK { + t.Fatalf("commit status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + + got, err := os.ReadFile(published) + if err != nil { + t.Fatalf("read published file: %v", err) + } + if len(got) != len(content) { + t.Fatalf("published %d bytes, want %d; a partial file was published", len(got), len(content)) + } + + // No staging residue may survive a successful commit, in the mount or + // beside it. A leftover .tmp is how a half-written assembly would show up. + var residue []string + _ = filepath.WalkDir(rootAbs, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return nil + } + name := entry.Name() + if strings.HasSuffix(name, ".tmp") || strings.HasSuffix(name, ".part") || strings.HasSuffix(name, ".complete") { + residue = append(residue, path) + } + return nil + }) + if len(residue) > 0 { + t.Errorf("staging residue left behind: %v", residue) + } +} + +// A commit that fails after assembly must still be retryable. +// +// The move consumes the staged segment, so a second attempt cannot reassemble +// from it. Commit therefore reuses an already-assembled file, which is sound +// because a recorded segment is immutable: re-uploading different bytes is +// refused with a conflict, so the assembled file can only hold what the session +// declared, and the checksum is verified on every attempt regardless. +// +// A destination conflict is the realistic way to land here, and it is +// recoverable -- the operator removes the blocker and retries. +func TestSingleSegmentCommitRetriesAfterConflict(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + content := []byte("retry me after the conflict clears") + session := createUploadSession(t, r, root.Name+"/single/retry.txt", content, int64(len(content)), false) + + if w := putSessionSegment(t, r, session.ID, 0, content); w.Result().StatusCode != http.StatusOK { + t.Fatalf("put segment status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + + rootAbs, err := svc.ResolveAbsPath(root.ID) + if err != nil { + t.Fatalf("resolve root: %v", err) + } + blocker := filepath.Join(rootAbs, "single", "retry.txt") + if err := os.MkdirAll(filepath.Dir(blocker), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(blocker, []byte("in the way"), 0o644); err != nil { + t.Fatalf("write blocker: %v", err) + } + + // onConflict=error, so this fails inside the publish step -- after the + // segment has already been moved into the assembled file. + first := commitSession(t, r, session.ID) + if first.Result().StatusCode != http.StatusConflict { + t.Fatalf("first commit status=%d body=%s, want 409", first.Result().StatusCode, first.Body.String()) + } + + if err := os.Remove(blocker); err != nil { + t.Fatalf("remove blocker: %v", err) + } + + second := commitSession(t, r, session.ID) + if second.Result().StatusCode != http.StatusOK { + t.Fatalf("retry after clearing the conflict status=%d body=%s", second.Result().StatusCode, second.Body.String()) + } + var out apiv1.UploadSessionCommitResponse + if err := json.NewDecoder(second.Result().Body).Decode(&out); err != nil { + t.Fatalf("decode commit: %v", err) + } + if out.Checksum != session.Checksum { + t.Errorf("commit checksum = %q, want %q", out.Checksum, session.Checksum) + } + + got, err := os.ReadFile(blocker) + if err != nil { + t.Fatalf("read published file: %v", err) + } + if string(got) != string(content) { + t.Fatalf("published content = %q, want %q", got, content) + } +} + +// A rejected commit must not publish anything, however cheap assembly became. +func TestSingleSegmentCommitRejectsChecksumMismatch(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + declared := []byte("what the session promised") + session := createUploadSession(t, r, root.Name+"/single/mismatch.txt", declared, int64(len(declared)), false) + + // Same length, different bytes: the size check cannot catch this, so the + // whole-file SHA-256 comparison has to. + actual := []byte("what the client actually!") + if len(actual) != len(declared) { + t.Fatalf("test setup: lengths differ (%d vs %d)", len(actual), len(declared)) + } + + w := httptest.NewRecorder() + req := authedJSONRequest(http.MethodPut, "/v1/uploads/sessions/"+session.ID+"/segments/0", actual) + r.ServeHTTP(w, req) + if w.Result().StatusCode != http.StatusOK { + t.Fatalf("put segment status=%d body=%s", w.Result().StatusCode, w.Body.String()) + } + + commit := commitSession(t, r, session.ID) + if commit.Result().StatusCode != http.StatusBadRequest { + t.Fatalf("commit status=%d body=%s, want 400", commit.Result().StatusCode, commit.Body.String()) + } + + rootAbs, err := svc.ResolveAbsPath(root.ID) + if err != nil { + t.Fatalf("resolve root: %v", err) + } + if _, err := os.Lstat(filepath.Join(rootAbs, "single", "mismatch.txt")); !os.IsNotExist(err) { + t.Errorf("a mismatched upload was published: %v", err) + } + if _, err := svc.ResolvePath(root.Name + "/single/mismatch.txt"); err == nil { + t.Error("a mismatched upload is resolvable through the index") + } else if !isNotFoundErr(err) { + t.Errorf("unexpected resolve error: %v", err) + } +} + +func isNotFoundErr(err error) bool { + return err != nil && (err == domain.ErrNotFound || strings.Contains(err.Error(), "not found")) +} diff --git a/adapter/s3/router.go b/adapter/s3/router.go index fb94030..5bb1790 100644 --- a/adapter/s3/router.go +++ b/adapter/s3/router.go @@ -9,6 +9,7 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "time" "github.com/valentinkolb/filegate/domain" @@ -120,7 +121,9 @@ func (kr keyRecord) canAccess(bucket string) bool { // an error when Options is misconfigured: missing credentials, a // duplicated access key, or a Keys entry whose bucket whitelist // references a mount that doesn't exist. -func NewHandler(svc *domain.Service, opts Options) (http.Handler, error) { +// NewHandler builds the S3 adapter. The returned Handler exposes SetKeys so +// access keys can be changed while the service runs. +func NewHandler(svc *domain.Service, opts Options) (*Handler, error) { if opts.Region == "" { opts.Region = "us-east-1" } @@ -130,10 +133,22 @@ func NewHandler(svc *domain.Service, opts Options) (http.Handler, error) { return nil, err } - auth := authConfig{ + r := &router{ + svc: svc, + limiter: newRateLimiter(opts.Keys), + accessLog: opts.AccessLogEnabled, + metrics: opts.Metrics, + activity: opts.ActivityLog, + writeSlots: make(chan struct{}, resolveMaxConcurrentWrites(opts.MaxConcurrentWrites)), + } + r.keys.Store(store) + + r.auth = authConfig{ Region: opts.Region, + // Reads through the atomic pointer, so a key deleted a moment ago is + // already gone for the request being signed right now. SecretForKeyID: func(keyID string) (string, bool) { - rec, ok := store.byAccessKey[keyID] + rec, ok := r.currentKeys().byAccessKey[keyID] if !ok { return "", false } @@ -146,21 +161,48 @@ func NewHandler(svc *domain.Service, opts Options) (http.Handler, error) { }, } - r := &router{ - svc: svc, - auth: auth, - keys: store, - limiter: newRateLimiter(opts.Keys), - accessLog: opts.AccessLogEnabled, - metrics: opts.Metrics, - activity: opts.ActivityLog, - writeSlots: make(chan struct{}, resolveMaxConcurrentWrites(opts.MaxConcurrentWrites)), - } // Sweep any active multipart uploads left in phase=committing across // crashes. Rows whose durable record exists are promoted to phase=done; // the rest are left for client-driven retry of Complete. recoverPendingMultipartUploads(svc) - return http.HandlerFunc(r.serve), nil + return &Handler{router: r, svc: svc, region: opts.Region}, nil +} + +// Handler serves the S3 API and owns the live access-key set. +type Handler struct { + router *router + svc *domain.Service + region string +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + h.router.serve(w, req) +} + +// SetKeys replaces the access-key set atomically. +// +// Validation happens before the swap, so a rejected update leaves the running +// key set untouched rather than half-applied. +func (h *Handler) SetKeys(keys []KeyEntry) error { + // An empty set is legitimate here, unlike at startup: deleting the last key + // must mean nothing authenticates, not that the previous set stays live. + if len(keys) == 0 { + h.router.keys.Store(&keyStore{byAccessKey: map[string]keyRecord{}}) + h.router.limiter = newRateLimiter(nil) + return nil + } + + store, err := buildKeyStore(Options{Keys: keys}, h.svc) + if err != nil { + return err + } + h.router.keys.Store(store) + h.router.limiter = newRateLimiter(keys) + return nil +} + +func (r *router) currentKeys() *keyStore { + return r.keys.Load() } // buildKeyStore folds the multi-tenant Keys list and the legacy @@ -238,9 +280,12 @@ func buildKeyStore(opts Options, svc *domain.Service) (*keyStore, error) { } type router struct { - svc *domain.Service - auth authConfig - keys *keyStore + svc *domain.Service + auth authConfig + // keys is swapped atomically when access keys are created, rotated or + // deleted, so a revoked key stops working on the next request rather than + // at the next restart. + keys atomic.Pointer[keyStore] limiter *rateLimiter // nil when no key has a configured limit accessLog bool metrics *metrics.Registry // nil-safe; receives the rate-limit reject counter @@ -329,7 +374,7 @@ func (s *uploadLockSet) acquire(uploadID string) func() { // any present key in the store is authoritative — the lookup is // just to retrieve the bucket whitelist. func (r *router) keyForRequest(verified *sigV4Result) (keyRecord, bool) { - rec, ok := r.keys.byAccessKey[verified.AccessKeyID] + rec, ok := r.currentKeys().byAccessKey[verified.AccessKeyID] return rec, ok } diff --git a/admin/README.md b/admin/README.md index 46a1afe..2ae0644 100644 --- a/admin/README.md +++ b/admin/README.md @@ -10,11 +10,73 @@ the TypeScript client. The browser only receives an admin session cookie. ```bash cd admin bun install -FILEGATE_URL=http://127.0.0.1:18080 FILEGATE_TOKEN=dev-token bun run dev +FILEGATE_URL=http://127.0.0.1:18080 \ +FILEGATE_TOKEN=dev-token \ +ADMIN_INSTANCE_NAME=fg-1-eu \ +ADMIN_TOKEN=dev-admin \ +ADMIN_SESSION_SECRET=dev-session-secret \ +bun run dev ``` -Open `http://127.0.0.1:3000` and sign in with `ADMIN_TOKEN` when set, otherwise -with `FILEGATE_TOKEN`. +Open `http://127.0.0.1:3000` and sign in with `ADMIN_TOKEN`. + +## Configuration + +| Variable | Required | Meaning | +|---|---:|---| +| `FILEGATE_URL` | yes | REST API base URL, reachable from the admin server. | +| `FILEGATE_TOKEN` | yes | Filegate bearer token, kept server-side. | +| `ADMIN_INSTANCE_NAME` | no | Instance name shown in the admin shell, login page and browser title. Defaults to `Filegate Admin`. | +| `ADMIN_TOKEN` | see note | Admin login token. Must differ from `FILEGATE_TOKEN`. Required unless OIDC is configured, where it stays useful as a break-glass login. | +| `ADMIN_SESSION_SECRET` | no | Session signing secret. Generated at boot when unset, which means sessions survive neither a restart nor a second replica. Set it in production. | +| `PORT` | no | Listen port, default `3000`. | +| `ADMIN_TRUST_PROXY` | no | Set when a reverse proxy sits in front, so `X-Forwarded-For` is used for rate limiting instead of the socket address. | +| `ADMIN_COOKIE_SECURE` | no | `auto` (default), `true` or `false`. Auto marks the session cookie `Secure` unless the request host is localhost. | +| `REDIS_URL` | no | Enables shared rate limiting across replicas. In-memory otherwise. | + +`ADMIN_TOKEN` no longer falls back to `FILEGATE_TOKEN`, and startup fails when +the two are equal: sharing them means guessing the admin login hands out the +Filegate master credential. `ADMIN_SESSION_SECRET` must likewise differ from both. + +## Single sign-on (OIDC) + +Setting these four together enables OIDC; leave them unset for token-only login. + +| Variable | Required | Meaning | +|---|---:|---| +| `OIDC_ISSUER` | yes | Issuer URL. Discovery reads `/.well-known/openid-configuration`. Must be https outside localhost. | +| `OIDC_CLIENT_ID` | yes | Client id. | +| `OIDC_CLIENT_SECRET` | yes | Client secret; stays server-side. | +| `OIDC_REDIRECT_URL` | yes | Must match the client's redirect URI, ending in `/auth/callback`. | +| `OIDC_SCOPES` | no | Default `openid profile email`. `openid` is added if missing. | +| `OIDC_GROUPS_CLAIM` | no | Claim holding group membership, default `groups`. | +| `OIDC_ALLOWED_GROUPS` | no | Comma-separated allowlist. **When unset, anyone your provider lets through this client becomes an admin.** | + +`OIDC_ALLOWED_GROUPS` is deliberately optional: providers such as Authentik bind +a group policy to the application itself, so a second allowlist here would be +duplicate bookkeeping. Leaving it unset delegates access control to the provider +and logs a warning at startup saying so. + +Authorization code flow with PKCE. The ID token is verified against the +provider's JWKS, and issuer, audience and nonce are all checked. Sessions last 12 +hours and are not refreshed; there is no call to the provider after login. + +When both are configured the login page offers both, and `ADMIN_TOKEN` remains a +break-glass path. Drop `ADMIN_TOKEN` to make single sign-on the only way in; the +token form then disappears. + +Logout is local to the admin app and does not end the session at the provider. + +## Sessions and login + +Sign-in issues a stateless signed session cookie carrying subject, label and +expiry, verified server-side on every request. There is no session store to run. + +`POST /login` is rate limited to 10 attempts per 5 minutes per client. The limiter +is in-memory by default; setting `REDIS_URL` switches it to a Redis-backed one +that is shared across replicas. Note that `REDIS_URL` must be present in the +process environment at launch, since the Redis connection is resolved from it at +startup and not re-read later. ## Uploads diff --git a/admin/bun.lock b/admin/bun.lock index 7f0723d..657cc0d 100644 --- a/admin/bun.lock +++ b/admin/bun.lock @@ -6,12 +6,15 @@ "name": "@valentinkolb/filegate-admin", "dependencies": { "@valentinkolb/filegate": "file:../sdk/ts", - "@valentinkolb/ssr": "^0.10.0", - "@valentinkolb/stdlib": "^0.13.0", + "@valentinkolb/ssr": "^0.11.2", + "@valentinkolb/stdlib": "^0.16.0", + "@valentinkolb/sync": "^5.6.0", "hono": "^4.12.25", + "jose": "^6.2.4", "solid-js": "^1.9.13", }, "devDependencies": { + "@tabler/icons-webfont": "^3.45.0", "@types/bun": "^1.3.4", "typescript": "^5.8.3", }, @@ -74,6 +77,10 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -84,6 +91,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="], + + "@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@tabler/icons": ["@tabler/icons@3.45.0", "", {}, "sha512-jiATwV8+zGYLTZ7gMLGivCic+KtsMZXcDmufIG8umlLxoHhI6902hGYIEt0Oa9Y9SXblNzUlrisHm5jOFMxOQA=="], + + "@tabler/icons-webfont": ["@tabler/icons-webfont@3.45.0", "", { "dependencies": { "@tabler/icons": "3.45.0", "svg-path-commander": "^2.1.11", "svgtofont": "^6.5.0" } }, "sha512-IvtB9TsEz3so6wCpOtIivE7n0SaS45nWpe1MuAxkarTyTs0DpYjrk59R+ybVE+PZYCtFCLmkuRKulXIesw6TfA=="], + + "@thednp/dommatrix": ["@thednp/dommatrix@3.0.4", "", {}, "sha512-xpKtQZqFOuAPfQDbePIh9f48YKfmULV7z9C+anPBSHiOG4P+T3ct7Cx2AZOoUeXbxiDkz7SONANy1bB7p2uysg=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -96,76 +115,474 @@ "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], + "@valentinkolb/filegate": ["@valentinkolb/filegate@file:../sdk/ts", { "devDependencies": { "typescript": "^5.8" } }], - "@valentinkolb/ssr": ["@valentinkolb/ssr@0.10.0", "", { "dependencies": { "@babel/core": "^7.24.0", "@babel/preset-typescript": "^7.24.0", "@types/babel__core": "^7.20.5", "babel-preset-solid": "^1.8.0", "seroval": "^1.0.0" }, "peerDependencies": { "@elysiajs/static": "^1.0.0", "elysia": "^1.0.0", "hono": "^4.0.0", "solid-js": "^1.9.0" }, "optionalPeers": ["@elysiajs/static", "elysia", "hono"] }, "sha512-X+o39uWUmfoqwAT9NA1Gaag5yZ5EKSz9kzfxctWJY2frhOOGF2YVZN7Zauwua1TbpsYcpt9GwCsZqXmj6QpjMg=="], + "@valentinkolb/ssr": ["@valentinkolb/ssr@0.11.2", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/preset-typescript": "^7.29.7", "@types/babel__core": "^7.20.5", "babel-preset-solid": "^1.9.12", "seroval": "^1.5.5" }, "peerDependencies": { "elysia": "^1.0.0", "hono": "^4.0.0", "solid-js": "^1.9.0" }, "optionalPeers": ["elysia", "hono"] }, "sha512-tL6youm0IcD8AETxgDtfgxBiBlCweE3WUL2Cj3ofmMWjgT9GslhLfH++8el/synBu27CAZO5+5D+F8L+svk4ig=="], + + "@valentinkolb/stdlib": ["@valentinkolb/stdlib@0.16.0", "", { "dependencies": { "dayjs": "^1.11.0" }, "peerDependencies": { "lean-qr": "", "solid-js": "" }, "optionalPeers": ["lean-qr", "solid-js"] }, "sha512-tNz+oIv//82RuLArP2jFPvRL/SPHmM5yKr/5pGSJsFQ1NH86s/kCK6K263J/hbztWh+rwojuRPUJm7a0QvN08A=="], + + "@valentinkolb/sync": ["@valentinkolb/sync@5.6.0", "", {}, "sha512-BzUNyxHcgWc+2VtNB3P6EfhjOXYWei1hhUsn2jfztQFgmnW4QmlfBFHXcTfzGJwNCXFpsKyDYCRC9UZxK3FOCQ=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + + "a-sync-waterfall": ["a-sync-waterfall@1.0.1", "", {}, "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA=="], - "@valentinkolb/stdlib": ["@valentinkolb/stdlib@0.13.0", "", { "dependencies": { "dayjs": "^1.11.0" }, "peerDependencies": { "lean-qr": "", "solid-js": "" }, "optionalPeers": ["lean-qr", "solid-js"] }, "sha512-dj4XWF0yvGp4drf3rHHUDq0LiVc9nrKCqwZJJxtfi4nXVhnZWfBC4nfSSl4W1MVCpWQJDUoBS/VpH8wAIo+Y9Q=="], + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], + + "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "auto-config-loader": ["auto-config-loader@2.0.2", "", { "dependencies": { "ini": "^5.0.0", "jiti": "^2.4.1", "jsonc-eslint-parser": "^2.3.0", "lodash.merge": "^4.6.2", "sucrase": "^3.35.0", "toml-eslint-parser": "^0.10.0", "yaml-eslint-parser": "^1.2.2" } }, "sha512-0V8gZAGGqiFDP15d6d4/Emi6Gpozbr1S9lSfxJ+lNV8nF+7grhcgbHIgn3O/DQKybS+cDqVMC3rxH8k+o0ISpA=="], "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.37", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "bufferstreams": ["bufferstreams@4.0.0", "", { "dependencies": { "readable-stream": "^3.4.0", "yerror": "^8.0.0" } }, "sha512-azX778/2VQ9K2uiYprSUKLgK2K6lR1KtJycJDsMg7u0+Cc994A9HyGaUKb01e/T+M8jse057429iKXurCaT35g=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001799", "", {}, "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw=="], + "cheerio": ["cheerio@1.0.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "encoding-sniffer": "^0.2.0", "htmlparser2": "^9.1.0", "parse5": "^7.1.2", "parse5-htmlparser2-tree-adapter": "^7.0.0", "parse5-parser-stream": "^7.1.2", "undici": "^6.19.5", "whatwg-mimetype": "^4.0.0" } }, "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "colors-cli": ["colors-cli@1.0.33", "", { "bin": { "colors": "bin/colors" } }, "sha512-PWGsmoJFdOB0t+BeHgmtuoRZUQucOLl5ii81NBzOOGVxlgE04muFNHlR5j8i8MKbOPELBl3243AI6lGBTj5ICQ=="], + + "commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cubic2quad": ["cubic2quad@1.2.1", "", {}, "sha512-wT5Y7mO8abrV16gnssKdmIhIbA9wSkeMzhh27jAguKrV82i24wER0vL5TGhUJ9dbJNDcigoRZ0IAHFEEEI4THQ=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.372", "", {}, "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "fs-extra": ["fs-extra@11.2.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw=="], + + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + "htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "image2uri": ["image2uri@2.1.2", "", { "dependencies": { "node-fetch": "^3.3.1" } }, "sha512-3b2zRma8I3zulb4OCkZruRw1VsnysT9phBzOJj+x3lPkwybJtNa5Sz6Dw8jSQI6OL7Ns4H5h8Y26EJbwq4GhQQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@5.0.0", "", {}, "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw=="], + + "ip-address": ["ip-address@10.3.1", "", {}, "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-eslint-parser": ["jsonc-eslint-parser@2.4.2", "", { "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", "espree": "^9.0.0", "semver": "^7.3.5" } }, "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="], + + "mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="], + + "microbuffer": ["microbuffer@1.0.0", "", {}, "sha512-O/SUXauVN4x6RaEJFqSPcXNtLFL+QzJHKZlyDVYFwcDDRVca3Fa/37QXXC+4zAGGa4YhHrHxKXuuHvLDIQECtA=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nan": ["nan@2.28.0", "", {}, "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], + "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "nunjucks": ["nunjucks@3.2.4", "", { "dependencies": { "a-sync-waterfall": "^1.0.0", "asap": "^2.0.3", "commander": "^5.1.0" }, "peerDependencies": { "chokidar": "^3.3.0" }, "optionalPeers": ["chokidar"], "bin": { "nunjucks-precompile": "bin/precompile" } }, "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "p-map": ["p-map@7.0.6", "", {}, "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], + + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], + "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "svg-path-commander": ["svg-path-commander@2.2.1", "", { "dependencies": { "@thednp/dommatrix": "^3.0.4" } }, "sha512-hZ9GOFBT/J31fTu3UCj+dZs1w4ZqiGCG/nQpFm1CEQ/EPFj9x3pHXPi6EjYuZmPlua7K8CdFc2+HxndiZ/Q3+g=="], + + "svg-pathdata": ["svg-pathdata@7.2.0", "", {}, "sha512-qd+AxqMpfRrRQaWb2SrNFvn69cvl6piqY8TxhYl2Li1g4/LO5F9NJb5wI4vNwRryqgSgD43gYKLm/w3ag1bKvQ=="], + + "svg2ttf": ["svg2ttf@6.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.9.10", "argparse": "^2.0.1", "cubic2quad": "^1.2.1", "lodash": "^4.17.10", "microbuffer": "^1.0.0", "svgpath": "^2.1.5" }, "bin": { "svg2ttf": "svg2ttf.js" } }, "sha512-EjxgcmhKcBpx/3fR1hPwVtJAbUc/ZsDpwOTF74SI3PbzCg4pDHnxVmoSuqgEqxVJGqqkSCI6+82cucpn2D5aOw=="], + + "svgicons2svgfont": ["svgicons2svgfont@15.0.1", "", { "dependencies": { "@types/sax": "^1.2.7", "commander": "^12.1.0", "debug": "^4.3.6", "glob": "^11.0.0", "sax": "^1.4.1", "svg-pathdata": "^7.0.0", "transformation-matrix": "^3.0.0", "yerror": "^8.0.0" }, "bin": { "svgicons2svgfont": "bin/svgicons2svgfont.js" } }, "sha512-rE3BoIipD6DxBejPswalKRZZYA+7sy4miHqiHgXB0zI1xJD3gSCVrXh2R6Sdh9E4XDTxYp7gDxGW2W8DIBif/g=="], + + "svgo": ["svgo@3.3.4", "", { "dependencies": { "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.0.0", "sax": "^1.5.0" }, "bin": "./bin/svgo" }, "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg=="], + + "svgpath": ["svgpath@2.6.0", "", {}, "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg=="], + + "svgtofont": ["svgtofont@6.5.3", "", { "dependencies": { "auto-config-loader": "^2.0.0", "cheerio": "~1.0.0", "colors-cli": "~1.0.28", "fs-extra": "~11.2.0", "image2uri": "^2.1.2", "nunjucks": "^3.2.4", "svg2ttf": "~6.1.0", "svgicons2svgfont": "~15.0.0", "svgo": "~3.3.0", "ttf2eot": "~3.1.0", "ttf2woff": "~3.0.0", "ttf2woff2": "~8.0.0", "yargs": "^17.7.2" }, "peerDependencies": { "@types/svg2ttf": "~5.0.1" }, "optionalPeers": ["@types/svg2ttf"], "bin": { "svgtofont": "lib/cli.js" } }, "sha512-koKMwoA+olMx7qY2LzdVF1+5/y0c1Tu1iZinTCClF4c5UVrSpExayMoyHqLB/58aTe5EUuE6htg5S2Y/MN4Azw=="], + + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "toml-eslint-parser": ["toml-eslint-parser@0.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0" } }, "sha512-9mjy3frhioGIVGcwamlVlUyJ9x+WHw/TXiz9R4YOlmsIuBN43r9Dp8HZ35SF9EKjHrn3BUZj04CF+YqZ2oJ+7w=="], + + "transformation-matrix": ["transformation-matrix@3.1.0", "", {}, "sha512-oYubRWTi2tYFHAL2J8DLvPIqIYcYZ0fSOi2vmSy042Ho4jBW2ce6VP7QfD44t65WQz6bw5w1Pk22J7lcUpaTKA=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "ttf2eot": ["ttf2eot@3.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "ttf2eot": "ttf2eot.js" } }, "sha512-aHTbcYosNHVqb2Qtt9Xfta77ae/5y0VfdwNLUS6sGBeGr22cX2JDMo/i5h3uuOf+FAD3akYOr17+fYd5NK8aXw=="], + + "ttf2woff": ["ttf2woff@3.0.0", "", { "dependencies": { "argparse": "^2.0.1", "pako": "^1.0.0" }, "bin": { "ttf2woff": "ttf2woff.js" } }, "sha512-OvmFcj70PhmAsVQKfC15XoKH55cRWuaRzvr2fpTNhTNer6JBpG8n6vOhRrIgxMjcikyYt88xqYXMMVapJ4Rjvg=="], + + "ttf2woff2": ["ttf2woff2@8.0.1", "", { "dependencies": { "bindings": "^1.5.0", "bufferstreams": "^4.0.0", "debug": "^4.4.1", "nan": "^2.22.2", "node-gyp": "^11.2.0", "yerror": "^8.0.0" }, "bin": { "ttf2woff2": "bin/ttf2woff2.js" } }, "sha512-nWSZLaXOgYtvgY6G0SFI8dVHsGWIchlnNMNRglT3Amp2WGy0GSPd9kLAkFd+HvEOzZ/aY6EUrpOF66QaPbipgg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], + + "unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yaml-eslint-parser": ["yaml-eslint-parser@1.3.2", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0", "yaml": "^2.0.0" } }, "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg=="], + + "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yerror": ["yerror@8.0.0", "", {}, "sha512-FemWD5/UqNm8ffj8oZIbjWXIF2KE0mZssggYpdaQkWDDgXBQ/35PNIxEuz6/YLn9o0kOxDBNJe8x8k9ljD7k/g=="], + + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@npmcli/fs/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + + "jsonc-eslint-parser/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "node-gyp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "solid-js/seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], + + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "svgicons2svgfont/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "svgo/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "cacache/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "cacache/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "cacache/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "cacache/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="], + + "cacache/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "cacache/glob/jackspeak/@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "cacache/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "cacache/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "cacache/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "cacache/glob/jackspeak/@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "cacache/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], } } diff --git a/admin/bunfig.toml b/admin/bunfig.toml new file mode 100644 index 0000000..ca551c9 --- /dev/null +++ b/admin/bunfig.toml @@ -0,0 +1,10 @@ +[test] +# Registers Solid's JSX transform; without it every rendered page throws +# "React is not defined" under bun test. +preload = ["./test/preload.ts"] + +# NOTE: run the suite with `bun run test`, not bare `bun test`. +# Each test file configures the admin differently (token-only, OIDC with an +# allowlist, OIDC-only) and the resolved environment is cached per process, so +# the files need one fresh global each. That is `--isolate`, which bunfig does +# not support, so it lives in the package.json test script. diff --git a/admin/package.json b/admin/package.json index 3c641f9..0260d16 100644 --- a/admin/package.json +++ b/admin/package.json @@ -7,16 +7,21 @@ "dev": "bun run build && bun dist/server.js", "build": "bun run build:sdk && bun run src/build.ts", "build:sdk": "bunx tsc -p ../sdk/ts/tsconfig.json && rm -rf node_modules/@valentinkolb/filegate/dist && cp -R ../sdk/ts/dist node_modules/@valentinkolb/filegate/dist", - "start": "bun dist/server.js" + "start": "bun dist/server.js", + "test": "bun test --isolate", + "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { "@valentinkolb/filegate": "file:../sdk/ts", - "@valentinkolb/ssr": "^0.10.0", - "@valentinkolb/stdlib": "^0.13.0", + "@valentinkolb/ssr": "^0.11.2", + "@valentinkolb/stdlib": "^0.16.0", + "@valentinkolb/sync": "^5.6.0", "hono": "^4.12.25", + "jose": "^6.2.4", "solid-js": "^1.9.13" }, "devDependencies": { + "@tabler/icons-webfont": "^3.45.0", "@types/bun": "^1.3.4", "typescript": "^5.8.3" } diff --git a/admin/src/app.tsx b/admin/src/app.tsx index f989e20..7854897 100644 --- a/admin/src/app.tsx +++ b/admin/src/app.tsx @@ -6,6 +6,9 @@ import { type BrowserUploadAllowResult, type BrowserUploadConflictMode, type CapabilitiesResponse, + type GlobSearchResponse, + type VersionResponse, + type HealthStatus, type DirectUploadURLResponse, type FileConflictMode, type MkdirConflictMode, @@ -16,17 +19,22 @@ import { type UploadSessionCreateRequest, type UploadSessionDirectRequest, } from "@valentinkolb/filegate"; -import { Hono } from "hono"; -import { login, logout, requireAuth } from "./lib/auth"; +import { Hono, type Context } from "hono"; +import { withActor } from "./lib/actor"; +import { resolveFileView } from "./lib/view"; +import { authMethods, login, logout, oidcBegin, oidcCallback, requireAuth } from "./lib/auth"; import { client, isList, parentPath, resolveDirectory } from "./lib/filegate"; import { env } from "./lib/env"; -import { errorMessage, redirectFiles, selectedFiles } from "./lib/format"; +import { errorMessage, formatRetryAfter, redirectFiles, selectedFiles } from "./lib/format"; import { config, routes, ssr } from "./config"; import { LoginPage } from "./components/Layout"; import { readThemeFromCookieHeader, type AdminTheme } from "./lib/theme"; import { Files } from "./pages/Files"; +import { filterNodes, sortNodes, type Sort, type SortField } from "./components/Table"; import { Overview } from "./pages/Overview"; +import { S3 } from "./pages/S3"; import { Search } from "./pages/Search"; +import { Settings } from "./pages/Settings"; import { System } from "./pages/System"; type Crumb = { name: string; path?: string }; @@ -55,6 +63,24 @@ export const app = new Hono() c.header("Content-Type", "text/javascript; charset=utf-8"); return new Response(Bun.file(new URL("./prompts.js", import.meta.url))); }) + .get("/toast.js", (c) => { + c.header("Content-Type", "text/javascript; charset=utf-8"); + return new Response(Bun.file(new URL("./toast.js", import.meta.url))); + }) + // Headers go on the Response, not the context: returning a fresh Response + // discards anything set via c.header. The Content-Type survives elsewhere in + // this file only because Bun.file infers it from the extension, which masks + // the same mistake for the other static routes. + .get("/tabler-icons.css", () => assetResponse("./tabler-icons.css", "text/css; charset=utf-8")) + .get("/fonts/tabler-icons.woff2", () => assetResponse("./fonts/tabler-icons.woff2", "font/woff2")) + .get("/system.js", (c) => { + c.header("Content-Type", "text/javascript; charset=utf-8"); + return new Response(Bun.file(new URL("./system.js", import.meta.url))); + }) + .get("/s3.js", (c) => { + c.header("Content-Type", "text/javascript; charset=utf-8"); + return new Response(Bun.file(new URL("./s3.js", import.meta.url))); + }) .get("/theme.js", (c) => { c.header("Content-Type", "text/javascript; charset=utf-8"); return new Response(Bun.file(new URL("./theme.js", import.meta.url))); @@ -64,19 +90,23 @@ export const app = new Hono() "/login", ...ssr(async (c) => { setPage(c, "Sign in"); - const hasError = c.req.query("error") === "invalid"; - return () => ; + const error = loginError(c.req.query("error"), c.req.query("retry"), c.req.query("reason")); + const methods = authMethods(); + return () => ; }), ) .post("/login", login) + .get("/auth/login", oidcBegin) + .get("/auth/callback", oidcCallback) .use("*", requireAuth()) + .use("*", withActor()) .post("/logout", logout) .get( "/", ...ssr(async (c) => { setPage(c, "Overview"); const data = await loadBase(); - return () => ; + return () => ; }), ) .get( @@ -85,7 +115,26 @@ export const app = new Hono() setPage(c, "Files"); const data = await loadFiles(c.req.query("path") || "", c.req.query("id") || ""); const loadError = "error" in data ? data.error : undefined; - return () => ; + const view = resolveFileView(c); + const sort = parseSort(c.req.query("sort"), c.req.query("dir")); + const filter = c.req.query("filter")?.trim() ?? ""; + // Sorting and filtering happen here rather than in the server: the whole + // directory is already loaded for the listing, so a round trip per sort + // click would buy nothing. + const totalBeforeFilter = data.children.length; + const children = sortNodes(filterNodes(data.children, filter), sort); + return () => ( + + ); }), ) .post("/files/mkdir", async (c) => { @@ -94,7 +143,7 @@ export const app = new Hono() try { const parent = await resolveDirectory(path); await client().nodes.mkdir(parent.id, { path: field(body, "name"), recursive: true, onConflict: mkdirConflictMode(field(body, "onConflict")) }); - return c.redirect(redirectFiles(path), 303); + return c.redirect(redirectFiles(path, undefined, "Folder created."), 303); } catch (err) { return c.redirect(redirectFiles(path, errorMessage(err)), 303); } @@ -122,9 +171,36 @@ export const app = new Hono() try { const node = await client().nodes.get(field(body, "id")); await client().nodes.delete(node.id); - return c.redirect(redirectFiles(parentPath(node.path)), 303); + return c.redirect(redirectFiles(parentPath(node.path), undefined, `${node.name} deleted.`), 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(redirectFiles(field(body, "parentPath"), errorMessage(err)), 303); + } + }) + .get("/files/thumbnail", async (c) => { + // The browser has no Filegate token, so thumbnails proxy through here. + // Conditional headers pass through both ways so the browser cache still + // works and repeat views cost a 304 rather than a re-encode. + const id = c.req.query("id")?.trim(); + if (!id) return c.notFound(); + const size = thumbnailSize(c.req.query("size")); + try { + const upstream = await client().nodes.thumbnailRaw(id, { size, ifNoneMatch: c.req.header("if-none-match") }); + if (upstream.status === 304) return new Response(null, { status: 304, headers: passthroughCacheHeaders(upstream) }); + if (!upstream.ok) { + // 415 unsupported, 413 too large, 503 queue full. The grid falls back + // to the file icon, so answering 404 is enough and keeps the browser + // from caching a failure as an image. + return c.notFound(); + } + return new Response(upstream.body, { + status: 200, + headers: { + "Content-Type": upstream.headers.get("content-type") ?? "image/jpeg", + ...passthroughCacheHeaders(upstream), + }, + }); + } catch { + return c.notFound(); } }) .get("/files/download", async (c) => { @@ -137,25 +213,106 @@ export const app = new Hono() }); return c.redirect(out.downloadUrl, 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(redirectFiles(c.req.query("parentPath") || "", errorMessage(err)), 303); } }) .post("/files/rename", async (c) => { const body = await c.req.parseBody(); try { const updated = await client().nodes.patch(field(body, "id"), { name: field(body, "name") }); - return c.redirect(selectedFiles(parentPath(updated.path), updated.id), 303); + return c.redirect(selectedFiles(parentPath(updated.path), updated.id, undefined, `Renamed to ${updated.name}.`), 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(selectedFiles(field(body, "parentPath"), field(body, "id"), errorMessage(err)), 303); } }) .post("/files/metadata", async (c) => { const body = await c.req.parseBody(); try { const updated = await client().nodes.patch(field(body, "id"), { ownership: ownershipFromForm(body) }, field(body, "recursiveOwnership") === "true"); - return c.redirect(selectedFiles(parentPath(updated.path), updated.id), 303); + return c.redirect(selectedFiles(parentPath(updated.path), updated.id, undefined, "Metadata updated."), 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(selectedFiles(field(body, "parentPath"), field(body, "id"), errorMessage(err)), 303); + } + }) + .post("/files/versions/snapshot", async (c) => { + const body = await c.req.parseBody(); + return versionAction(c, body, "Snapshot created.", () => client().versions.snapshot(field(body, "id"), field(body, "label") || undefined)); + }) + .post("/files/versions/pin", async (c) => { + const body = await c.req.parseBody(); + return versionAction(c, body, "Version pinned.", () => client().versions.pin(field(body, "id"), field(body, "versionId"), field(body, "label") || undefined)); + }) + .post("/files/versions/unpin", async (c) => { + const body = await c.req.parseBody(); + return versionAction(c, body, "Version unpinned.", () => client().versions.unpin(field(body, "id"), field(body, "versionId"))); + }) + .post("/files/versions/delete", async (c) => { + const body = await c.req.parseBody(); + return versionAction(c, body, "Version deleted.", () => client().versions.delete(field(body, "id"), field(body, "versionId"))); + }) + .post("/files/versions/restore", async (c) => { + const body = await c.req.parseBody(); + const asNewFile = field(body, "asNewFile") === "true"; + try { + const out = await client().versions.restore(field(body, "id"), field(body, "versionId"), { + asNewFile, + name: field(body, "name") || undefined, + }); + // An as-new restore produces a different node, so select that one. + return c.redirect(selectedFiles(parentPath(out.node.path), out.node.id, undefined, restoreNotice(out.asNew)), 303); + } catch (err) { + return c.redirect(selectedFiles(field(body, "parentPath"), field(body, "id"), errorMessage(err)), 303); + } + }) + .get("/files/versions/download", async (c) => { + const id = c.req.query("id")?.trim(); + const versionId = c.req.query("versionId")?.trim(); + if (!id || !versionId) return c.redirect(redirectFiles("", "file and version are required"), 303); + try { + // Version bytes have no signed direct-URL endpoint, so unlike normal + // downloads this one streams through the admin server. + const upstream = await client().versions.contentRaw(id, versionId); + return new Response(upstream.body, { + status: upstream.status, + headers: { + "Content-Type": upstream.headers.get("content-type") ?? "application/octet-stream", + "Content-Disposition": upstream.headers.get("content-disposition") ?? `attachment; filename="${versionId}.bin"`, + }, + }); + } catch (err) { + return c.redirect(redirectFiles(c.req.query("parentPath") || "", errorMessage(err)), 303); + } + }) + .post("/files/bulk/delete", async (c) => { + const body = await c.req.parseBody(); + const parentPath = field(body, "parentPath"); + const ids = splitCSV(field(body, "ids")); + if (ids.length === 0) return c.redirect(redirectFiles(parentPath, "no items selected"), 303); + + const outcome = await runBulk(ids, (id) => client().nodes.delete(id)); + return c.redirect(redirectFiles(parentPath) + bulkQuery(parentPath, "Deleted", outcome), 303); + }) + .post("/files/bulk/move", async (c) => { + const body = await c.req.parseBody(); + const parentPath = field(body, "parentPath"); + const ids = splitCSV(field(body, "ids")); + if (ids.length === 0) return c.redirect(redirectFiles(parentPath, "no items selected"), 303); + + try { + const target = await resolveDirectory(field(body, "targetParentPath")); + const outcome = await runBulk(ids, async (id) => { + const node = await client().nodes.get(id); + await client().transfers.create({ + op: "move", + sourceId: id, + targetParentId: target.id, + targetName: node.name, + onConflict: conflictMode(field(body, "onConflict")), + }); + }); + return c.redirect(redirectFiles(parentPath) + bulkQuery(parentPath, "Moved", outcome), 303); + } catch (err) { + return c.redirect(redirectFiles(parentPath, errorMessage(err)), 303); } }) .post("/files/transfer", async (c) => { @@ -169,22 +326,31 @@ export const app = new Hono() targetName: field(body, "targetName"), onConflict: conflictMode(field(body, "onConflict")), }); - return c.redirect(selectedFiles(parentPath(out.node.path), out.node.id), 303); + const verb = field(body, "op") === "copy" ? "Copied" : "Moved"; + return c.redirect(selectedFiles(parentPath(out.node.path), out.node.id, undefined, `${verb} ${out.node.name}.`), 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(selectedFiles(field(body, "parentPath"), field(body, "id"), errorMessage(err)), 303); } }) .get( "/search", ...ssr(async (c) => { setPage(c, "Search"); - const stats = await loadStats(); + const base = await loadBase(); const pattern = c.req.query("pattern") || ""; const hidden = c.req.query("hidden") === "true"; - const results = pattern - ? await client().search.glob({ pattern, limit: 100, showHidden: hidden, files: true, directories: true }) - : undefined; - return () =>