gotifacts

module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 11, 2026 License: MIT

README

gotifacts

CI Go Report Card

A single, self-hosted Go service that hosts static sites by host-based routing and serves a dynamic portal to browse them. You publish sites over an HTTP API; gotifacts stores them on a volume with a SQLite registry and serves them at https://<slug>.<group>.<base>.

gotifacts runs behind any reverse proxy you provide (nginx, Caddy, …) for TLS and SSO/forward-auth. It serves plain HTTP on one port, never TLS, and enforces its own authorization.

  • One static binary (CGO-free, built FROM scratch).
  • SQLite + a volume are the only state.
  • No hardcoded domains/hosts/paths — everything is configurable.

Table of contents


How it works

reverse proxy (operator-provided: TLS, forward-auth/SSO)
  ├── apex "/" and "/api/*"   → forward-auth ON  → portal UI + management API
  ├── apex "/ingest/*"        → forward-auth OFF → machine publish API (API-key)
  └── *.base, *.*.base        → static site content
                                      │  HTTP :8080
                                      ▼
                 ┌──────────────────────────────────────────┐
                 │  gotifacts (Go, static scratch binary)     │
                 │  Host router:                              │
                 │   Host == base → portal + /api + /ingest   │
                 │   else         → serve site files          │
                 │  Registry + API keys: SQLite (modernc)     │
                 └──────────────┬─────────────────────────────┘
                                ▼ volume (rw)
        /data/gotifacts.db  +  /data/sites/<group…>/<slug>/{index.html, assets…}

The service routes purely by the request Host:

  • Apex host (== GOTIFACTS_BASE_DOMAIN): serves the portal SPA, the management API (/api/*), and the ingest API (/ingest/*).
  • Any other host: maps the host to a site directory and serves static files.

Two-plane auth model

gotifacts has two independent authorization planes:

Plane Routes Authenticated by Used by
Management /, /api/* a forward-auth identity header injected by your proxy the browser (portal)
Ingest /ingest/* a scoped API key (Authorization: Bearer <key>) machines (CI, the Claude skill)
  • The identity header (GOTIFACTS_FORWARD_AUTH_HEADER, default Remote-User) is honored only when the request's direct peer IP is within GOTIFACTS_TRUSTED_PROXIES. From any other source it is stripped and ignored.
  • The principal is that user; they are admin iff they are listed in GOTIFACTS_ADMIN_USERS.
  • On the ingest plane the identity header is irrelevant — only the API key counts. Leave /ingest/* out of your proxy's forward-auth.

No API key ever lives in the browser. The portal authenticates you purely via the proxy-injected header.

URL ⇄ path convention

Strip base_domain from a host. The remaining sub-labels, read left→right, run [most-specific … least-specific]. The served directory is those labels reversed:

Host Served directory group slug
app.claude.<base> sites/claude/app claude app
a.sub.grp.<base> sites/grp/sub/a grp/sub a
demo.<base> sites/demo (flat) demo

Rules:

  • Total depth (group segments + slug) ≤ 3. Deeper hosts are rejected.
  • Each label must match ^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$.
  • A site is identified on publish by group (0–2 segments, may be empty) + slug (the leaf).

Permissions & API keys

Access on the ingest plane is capability-based. A key is either an admin superuser or a set of grants, each binding a set of capabilities to a target:

Capability Allows
publish create/replace sites — POST /ingest/sites
unpublish delete sites — DELETE /ingest/sites
rollback restore a site's previous version — POST /ingest/sites/…/rollback
patch edit site metadata — PATCH /ingest/sites
Role Granted by Can do
Admin forward-auth allowlist or an admin key everything: manage keys + all capabilities on every site
Scoped key a key with one or more grants only the granted capabilities, confined to each grant's target
Viewer any authenticated forward-auth user view the portal and GET /api/sites

A grant's target is either a group or a single site:

  • A group grant on docs owns the docs subdomain and everything beneath it: docs.<base> itself (the flat site group "", slug docs) and every site under *.docs.<base> (e.g. app.docs.<base> = group docs, slug app).
  • A site grant on docs/app is confined to exactly that one site (app.docs.<base>) — not its children, not its siblings.
  • A group grant with an empty target means all sites (global). Targets are free-text (each label must match ^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$) — you don't pre-register them.

API keys:

  • Token format gtf_<base64url-32B>, shown in plaintext once at creation.

  • Only the SHA-256 hash is stored; lookups are constant-time.

  • Optionally expire: a key may carry an expiry instant (or never expire, the default); once past it, the key is rejected like an unknown token.

  • Mint them in the portal (API Keys view, admin only) or via the CLI:

    # A CI key that can deploy AND tear down PR previews — no admin rights:
    gotifacts keys create --name ci --grant "previews:publish,unpublish"
    
    # A key confined to a single site:
    gotifacts keys create --name docs-bot --grant-site "docs/app:publish,patch"
    
    # An expiring key (also: --expires-at 2026-12-31):
    gotifacts keys create --name temp --grant "docs:publish" --expires-in 720h
    
    # Multiple grants, a global grant, and an admin key:
    gotifacts keys create --name release --grant "claude:publish" --grant ":unpublish"
    gotifacts keys create --name root --admin
    
    gotifacts keys list
    gotifacts keys revoke --id 3
    

    --grant "group:caps" targets a group subtree (empty group = all sites); --grant-site "group/slug:caps" targets one exact site.

There is no bootstrap key: set GOTIFACTS_ADMIN_USERS, log in through your proxy, and create keys in the UI (the CLI is the headless fallback).

Backward compatibility: existing tokens keep working unchanged. A migration backfills grants from the old scope/group_restriction model — old publish keys get an equivalent publish grant; old admin keys become admin superusers.

API reference

Management plane — /api/* (forward-auth)
Method & path Scope Description
GET /api/me viewer+ { user, is_admin, base_domain }
GET /api/sites viewer+ Flat list and nested group tree. Query: q, tag, group, sort (date|title|slug), hidden=true (admin), limit, offset
POST /api/sites admin Manual upload (same multipart body as ingest)
PATCH /api/sites/{group…}/{slug} admin Metadata-only update
DELETE /api/sites/{group…}/{slug} admin Delete site + files
POST /api/sites/{group…}/{slug}/rollback admin Restore the latest archived version
GET /api/keys admin List keys (no plaintext)
POST /api/keys admin {name, admin?, grants:[{kind:"group"|"site", target, permissions:[…]}], expires_at?} (expires_at is RFC3339 or YYYY-MM-DD) → 201 {id, name, admin, grants, expires_at, key} (plaintext once). Legacy {name, scope, group?} is still accepted.
DELETE /api/keys/{id} admin Revoke a key
Ingest plane — /ingest/* (API key)

POST /ingest/sites — create or replace a site. multipart/form-data:

  • meta — JSON: {group, slug, title, description?, date?, tags?, repo?, preview?, hidden?}
  • either bundle — a .tar.gz containing a top-level index.html
  • or index — a single self-contained HTML document (becomes index.html)

Requires the publish capability on the target group (admin keys always pass). Idempotent (same group/slug replaces). Response:

{ "url": "https://app.claude.example.com", "group": "claude", "slug": "app", "updated_at": "2026-06-04T..." }

DELETE /ingest/sites/{group…}/{slug} — requires the unpublish capability on the group (automation cleanup, e.g. PR-preview teardown). PATCH requires patch; POST …/rollback requires rollback.

Example publish of a single HTML file:

printf '{"group":"claude","slug":"app","title":"My App"}' > meta.json
curl -fsS \
  -H "Authorization: Bearer $GOTIFACTS_API_KEY" \
  -F 'meta=<meta.json;type=application/json' \
  -F 'index=@index.html;type=text/html' \
  https://example.com/ingest/sites

Configuration

All configuration is via environment variables (no config file). See .env.example for the annotated reference.

Variable Default Notes
GOTIFACTS_LISTEN_ADDR :8080 HTTP bind address
GOTIFACTS_DATA_DIR /data Volume root (DB + site files)
GOTIFACTS_DB_PATH ${DATA_DIR}/gotifacts.db SQLite path
GOTIFACTS_BASE_DOMAIN Required. Apex domain
GOTIFACTS_FORWARD_AUTH_HEADER Remote-User Identity header from the proxy
GOTIFACTS_ADMIN_USERS Comma-separated admin users
GOTIFACTS_TRUSTED_PROXIES Comma-separated CIDRs/IPs allowed to assert the identity header (required for the management plane)
GOTIFACTS_MAX_UPLOAD_BYTES 67108864 (64 MiB) Ingest body cap
GOTIFACTS_MAX_EXTRACT_BYTES 268435456 (256 MiB) Decompressed archive cap
GOTIFACTS_MAX_EXTRACT_ENTRIES 10000 Archive entry cap
GOTIFACTS_VERSIONING_ENABLED false Keep old versions on replace; enable rollback
GOTIFACTS_VERSIONING_KEEP 5 Versions retained per site

gotifacts serve refuses to start if no admins are reachable (no GOTIFACTS_ADMIN_USERS + GOTIFACTS_TRUSTED_PROXIES); use the CLI to mint an admin key for purely headless setups.

Running with Docker

A proxy-agnostic docker-compose.yml is provided. The image is published to ghcr.io/lmgarret/gotifacts.

cp .env.example .env
# edit .env: set GOTIFACTS_BASE_DOMAIN, GOTIFACTS_ADMIN_USERS, GOTIFACTS_TRUSTED_PROXIES
docker compose up -d

gotifacts is reachable only on the internal network (expose: 8080). Put your reverse proxy in front of it — never expose port 8080 to the internet.

The image is a three-stage build (Node → Go → scratch): a static, non-root, CA-cert-only runtime that declares /data as a volume.

Reverse proxy setup

gotifacts is proxy-agnostic. Reference snippets (illustrative, adapt to your SSO provider):

Each shows: TLS; apex / + /api/* behind forward-auth with the identity header injected (and any client-supplied copy stripped); apex /ingest/* with forward-auth off; and *.base / *.*.base serving site content.

Framing sites in the portal

The portal renders live, sandboxed iframe thumbnails of sites. For a site to be framable by the portal, it must be served with:

Content-Security-Policy: frame-ancestors https://<base>

The proxy examples add this header to site responses. If you set preview in a site's metadata, the portal uses that image instead of an iframe.

Security

See SECURITY.md for the threat model and private reporting. Highlights:

  • Identity-header spoofing is the top risk. The header is honored only from GOTIFACTS_TRUSTED_PROXIES; otherwise stripped. Never expose gotifacts directly; your proxy must strip any client-supplied identity header before injecting the real one.
  • Uploads are guarded against zip-slip, symlink escapes, tar-bombs, and oversized payloads. Sites are written to a temp dir on the same volume, validated, then atomically swapped into place.
  • API keys are hashed at rest, shown once, compared in constant time, and never logged.

Publishing from CI or Claude

A distributable Claude skill lives in examples/skill/. It asks for consent, writes a self-contained index.html, picks a URL-safe slug/group (default claude), publishes via the single-index ingest form using GOTIFACTS_URL + a GOTIFACTS_API_KEY holding the publish capability, and reports the URL. It never touches server/proxy credentials.

Development

See CONTRIBUTING.md. In short:

go test -race ./...          # backend tests
golangci-lint run ./...      # backend lint
cd web && npm ci && npm run lint && npm run build   # frontend

The Go module path is github.com/lmgarret/gotifacts; go.mod is the single source of truth for the Go version.

License

MIT.

Directories

Path Synopsis
cmd
gotifacts command
Command gotifacts is a self-hosted service that hosts static sites by host-based routing and serves a dynamic management portal.
Command gotifacts is a self-hosted service that hosts static sites by host-based routing and serves a dynamic management portal.
internal
api
Package api wires the management plane (/api/*, forward-auth) and the ingest plane (/ingest/*, API-key) onto the apex host, plus the embedded SPA.
Package api wires the management plane (/api/*, forward-auth) and the ingest plane (/ingest/*, API-key) onto the apex host, plus the embedded SPA.
archive
Package archive safely extracts site bundles, defending against zip-slip, tar-bombs, symlink escapes, and oversized payloads.
Package archive safely extracts site bundles, defending against zip-slip, tar-bombs, symlink escapes, and oversized payloads.
auth
Package auth implements the two-plane authorization model.
Package auth implements the two-plane authorization model.
config
Package config loads and validates the gotifacts runtime configuration.
Package config loads and validates the gotifacts runtime configuration.
ingest
Package ingest implements site publishing: staging an upload to a temp dir on the same volume, validating it, atomically swapping it into place (with optional versioning), and recording the registry row in a transaction.
Package ingest implements site publishing: staging an upload to a temp dir on the same volume, validating it, atomically swapping it into place (with optional versioning), and recording the registry row in a transaction.
keys
Package keys handles API-key token generation, hashing, and scope semantics.
Package keys handles API-key token generation, hashing, and scope semantics.
portal
Package portal serves static site content (host-routed) and the embedded management SPA.
Package portal serves static site content (host-routed) and the embedded management SPA.
router
Package router implements the URL ⇄ filesystem-path convention that is the core contract of gotifacts, plus host-based request classification.
Package router implements the URL ⇄ filesystem-path convention that is the core contract of gotifacts, plus host-based request classification.
store
Package store is the SQLite-backed registry: the sole source of truth for sites and API keys.
Package store is the SQLite-backed registry: the sole source of truth for sites and API keys.
Package web embeds the built management SPA (web/dist) for serving by the portal.
Package web embeds the built management SPA (web/dist) for serving by the portal.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL