Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
e69bab3
docs: highlight admin ui
ValentinKolb Jun 26, 2026
1ee94c8
feat(admin): per-user sessions and login hardening
ValentinKolb Jul 25, 2026
e9eb091
feat(admin): add OIDC single sign-on
ValentinKolb Jul 25, 2026
81001e8
feat(infra): expose jobs, cache and detector internals
ValentinKolb Jul 25, 2026
a987d40
feat(api): add system info, runtime and health endpoints
ValentinKolb Jul 25, 2026
a4c5124
feat: expose detector and cache metrics, log auth failures
ValentinKolb Jul 25, 2026
2b5b37b
feat(admin): name the signed-in admin in Filegate audit entries
ValentinKolb Jul 25, 2026
bbc2b75
fix(admin): stop the UI misleading operators
ValentinKolb Jul 25, 2026
5b81f94
feat(admin): selectable upload conflict mode, cancellable uploads
ValentinKolb Jul 25, 2026
0f06ddf
feat(admin): add version history to the files view
ValentinKolb Jul 25, 2026
97901c5
feat(admin): thumbnails and a grid view for files
ValentinKolb Jul 25, 2026
46e0e85
feat(admin): upload dialog with a dropzone, remembered grid layout
ValentinKolb Jul 25, 2026
2b24f11
feat(cli): classify every config key as static or runtime
ValentinKolb Jul 25, 2026
7b5d6f0
feat(infra): add the runtime config store
ValentinKolb Jul 25, 2026
d611904
feat(cli): resolve config from layered sources into a live snapshot
ValentinKolb Jul 26, 2026
8a2e948
feat(cli): open the runtime store and publish the live snapshot
ValentinKolb Jul 26, 2026
9f65c5c
feat(api): add the configuration endpoints
ValentinKolb Jul 26, 2026
a681df2
feat(api): read runtime settings from the live snapshot
ValentinKolb Jul 26, 2026
56ffa11
feat(s3): manage access keys as runtime resources
ValentinKolb Jul 26, 2026
48bd9c8
feat(cli): start with no configuration at all
ValentinKolb Jul 26, 2026
c7ac50f
test(cli): pin the new optional-bearer contract
ValentinKolb Jul 26, 2026
6863924
feat(admin): settings page for configuration and S3 keys
ValentinKolb Jul 26, 2026
8e36750
feat(admin): bulk selection, sorting and folder filtering
ValentinKolb Jul 26, 2026
721ed5e
fix(admin): settings table borders and panel spacing
ValentinKolb Jul 26, 2026
d4cfbbb
feat(admin): format setting values, fix scrollbar layout shift
ValentinKolb Jul 26, 2026
3facc07
fix(api): publish retention buckets in a deliberate shape, make them …
ValentinKolb Jul 26, 2026
2821a97
feat(admin): structured editor for the version retention policy
ValentinKolb Jul 26, 2026
edbfb3b
feat(admin): stop leaking S3 secrets through URLs, add unit editors
ValentinKolb Jul 26, 2026
7a14356
feat(admin): icons throughout, self-hosted Tabler webfont
ValentinKolb Jul 26, 2026
373f968
feat(admin): icons on every button, explicit units on numeric settings
ValentinKolb Jul 26, 2026
3b2f748
feat(admin): live observability dashboard
ValentinKolb Jul 26, 2026
b4f941d
feat: manual prune, chips editor, validation before saving
ValentinKolb Jul 26, 2026
2ba4d6d
fix(sdk): send the upload-session phase filter as a query parameter
ValentinKolb Jul 26, 2026
ab4d0d4
test(http): pin upload-session token scope and abort cleanup
ValentinKolb Jul 26, 2026
48a5c7e
feat(sdk): upload whole folders from Go over many sessions
ValentinKolb Jul 26, 2026
2629a5a
bench(uploads): measure many-small-files upload throughput
ValentinKolb Jul 26, 2026
6071683
fix(domain): index the parent before anchoring a child to it
ValentinKolb Jul 26, 2026
15afe4c
docs: generate the config reference and state the beta scope
ValentinKolb Jul 26, 2026
a1175a6
perf(uploads): move a lone segment into place instead of copying it
ValentinKolb Jul 26, 2026
1207012
feat(sdk): default small files to one-shot uploads
ValentinKolb Jul 26, 2026
d5c187d
bench: measure where commit time actually goes
ValentinKolb Jul 26, 2026
a7be336
perf(uploads): stop paying a durable write per directory level
ValentinKolb Jul 26, 2026
8fe20df
feat(server): optional h2c on the REST listener
ValentinKolb Jul 26, 2026
4594a90
feat(admin): improve operator feedback and settings
ValentinKolb Jul 27, 2026
5d2f99f
feat(config): adopt declarative manifests
ValentinKolb Jul 27, 2026
151258e
feat(admin): polish responsive shell and branding
ValentinKolb Aug 4, 2026
ab5344b
docs: align admin and S3 resource guidance
ValentinKolb Aug 4, 2026
31f605d
fix(filesystem): repair malformed file IDs
ValentinKolb Aug 4, 2026
072fb02
fix(config): honor runtime upload settings
ValentinKolb Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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 ./...
Expand Down Expand Up @@ -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
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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 '<strong-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'
Expand Down Expand Up @@ -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`.
Expand Down
73 changes: 73 additions & 0 deletions adapter/http/config.go
Original file line number Diff line number Diff line change
@@ -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"
}
99 changes: 99 additions & 0 deletions adapter/http/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
19 changes: 8 additions & 11 deletions adapter/http/direct_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/netip"
"strings"
"time"

Expand All @@ -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 {
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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
Expand Down
28 changes: 11 additions & 17 deletions adapter/http/direct_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion adapter/http/direct_upload_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading