diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 1eeb1d9..437ccbc 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -5,10 +5,23 @@ "resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a", "integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a" }, - "ghcr.io/devcontainers/features/node:2.0.0": { - "version": "2.0.0", - "resolved": "ghcr.io/devcontainers/features/node@sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f", - "integrity": "sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f" + "ghcr.io/devcontainers-community/npm-features/typescript:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers-community/npm-features/typescript@sha256:13a0f63e88513a6022431c39b7ca4ec732ba0760cdb6d882638f4ddf73deb0e7", + "integrity": "sha256:13a0f63e88513a6022431c39b7ca4ec732ba0760cdb6d882638f4ddf73deb0e7", + "dependsOn": [ + "ghcr.io/devcontainers/features/node" + ] + }, + "ghcr.io/devcontainers/features/go:1": { + "version": "1.3.4", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032" + }, + "ghcr.io/devcontainers/features/node:2.1.0": { + "version": "2.1.0", + "resolved": "ghcr.io/devcontainers/features/node@sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857", + "integrity": "sha256:586c9a6f7dd40bd3ba2cd41e7f2f88dcc31fbe5d1442afcbf07ffbc66b686857" }, "ghcr.io/jsburckhardt/devcontainer-features/just:1": { "version": "1.0.0", diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a76620f..423afc5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,8 +1,11 @@ { "image": "mcr.microsoft.com/devcontainers/base:ubuntu", "features": { - "ghcr.io/devcontainers/features/node:2.0.0": {}, + "ghcr.io/devcontainers/features/node:2.1.0": {}, + "ghcr.io/devcontainers/features/go:1": {}, "ghcr.io/jsburckhardt/devcontainer-features/just:1": {}, - "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, + "ghcr.io/devcontainers/features/node:2": {}, + "ghcr.io/devcontainers-community/npm-features/typescript:1": {} } } \ No newline at end of file diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..2e48491 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,73 @@ +name: Deploy + +# Builds the static frontend and the Go share-service, ships both to the +# Caddy host, and restarts the service. +# +# Required repo secret: +# DEPLOY_SSH_KEY - private SSH key authorized for ${DEPLOY_USER}@${DEPLOY_HOST} +# +# Override host/user/paths via repo variables (Settings > Actions > Variables) +# if they ever change; the defaults below match the Justfile. + +on: + push: + branches: [master] + workflow_dispatch: + +env: + DEPLOY_HOST: ${{ vars.DEPLOY_HOST || 'goldfish.malzahn.lan' }} + DEPLOY_USER: ${{ vars.DEPLOY_USER || 'root' }} + WWW_ROOT: ${{ vars.WWW_ROOT || '/usr/share/caddy/public_html/fabula' }} + GO_ARCH: ${{ vars.GO_ARCH || 'amd64' }} + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # --- Build frontend --- + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run build # -> dist/ + + # --- Build backend (static, no cgo) --- + - uses: actions/setup-go@v5 + with: + go-version-file: server/go.mod + cache-dependency-path: server/go.sum + - name: Build share-svc + working-directory: server + run: CGO_ENABLED=0 GOOS=linux GOARCH="$GO_ARCH" go build -o share-svc . + + # --- Deploy --- + - name: Configure SSH + run: | + command -v rsync >/dev/null || { apt-get update && apt-get install -y rsync; } + mkdir -p ~/.ssh && chmod 700 ~/.ssh + install -m 600 /dev/stdin ~/.ssh/deploy_key <<< "${{ secrets.DEPLOY_SSH_KEY }}" + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Copy frontend + run: | + rsync -az --delete -e "ssh -i ~/.ssh/deploy_key" \ + dist/ "$DEPLOY_USER@$DEPLOY_HOST:$WWW_ROOT/" + + - name: Copy backend binary + unit file + run: | + scp -i ~/.ssh/deploy_key server/share-svc \ + "$DEPLOY_USER@$DEPLOY_HOST:/usr/local/bin/share-svc" + scp -i ~/.ssh/deploy_key server/share-svc.service \ + "$DEPLOY_USER@$DEPLOY_HOST:/etc/systemd/system/share-svc.service" + + - name: Restart services + run: | + ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" ' + systemctl daemon-reload && + systemctl enable --now share-svc && + systemctl restart share-svc && + systemctl reload caddy + ' diff --git a/Justfile b/Justfile index 130f416..4cd6b11 100644 --- a/Justfile +++ b/Justfile @@ -6,8 +6,12 @@ user := "root" host := "goldfish.malzahn.lan" www-root := "/usr/share/caddy/public_html/fabula" +# Target architecture of the deploy host (override: `just go-arch=arm64 deploy-server`) +go-arch := "amd64" + clean: rm -rf dist/* + rm -f server/share-svc serve: npm run dev @@ -18,6 +22,16 @@ build: deploy: build scp -r dist/* {{ user }}@{{ host }}:{{ www-root }}/ +# Build the share service as a static binary for the deploy host. +build-server: + cd server && CGO_ENABLED=0 GOOS=linux GOARCH={{ go-arch }} go build -o share-svc . + +# Ship the binary + unit file and (re)start the service. Requires root on host. +deploy-server: build-server + scp server/share-svc {{ user }}@{{ host }}:/usr/local/bin/share-svc + scp server/share-svc.service {{ user }}@{{ host }}:/etc/systemd/system/share-svc.service + ssh {{ user }}@{{ host }} 'systemctl daemon-reload && systemctl enable --now share-svc && systemctl restart share-svc' + format: npx prettier --write books/ npx prettier --write src/ diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..a2ba4c1 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,7 @@ +# Compiled binary +/share-svc + +# SQLite database + WAL/SHM sidecars +*.db +*.db-wal +*.db-shm diff --git a/server/Caddyfile.snippet b/server/Caddyfile.snippet new file mode 100644 index 0000000..31847f5 --- /dev/null +++ b/server/Caddyfile.snippet @@ -0,0 +1,21 @@ +# Caddy config for the share-link service. +# +# The frontend (served under /fabula/) calls /fabula/api/s, but the Go service +# routes plain /api/s. `uri strip_prefix /fabula` rewrites /fabula/api/s -> +# /api/s before proxying to the service on loopback. +# +# Place this INSIDE your existing site block, and make sure this /fabula/api/* +# handler comes BEFORE the static handler that serves /fabula/* — `handle` +# blocks match in source order, first match wins. + +handle /fabula/api/* { + uri strip_prefix /fabula + reverse_proxy 127.0.0.1:8090 +} + +# ... your existing static file handler for /fabula/* follows here, e.g.: +# +# handle /fabula/* { +# root * /usr/share/caddy/public_html +# file_server +# } diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..a3f4211 --- /dev/null +++ b/server/README.md @@ -0,0 +1,51 @@ +# share-svc + +Tiny Go + SQLite service that stores character-sheet JSON blobs and returns +short, content-addressed IDs, so sheets share as `...?s=` instead of a giant +inline `?c=` payload. + +## API + +| Method | Path | Body | Response | +| ------ | ------------ | ----------- | ------------------- | +| POST | `/api/s` | sheet JSON | `{"id":""}` | +| GET | `/api/s/{id}`| – | sheet JSON | + +IDs are `base64url(sha256(json))[:12]`, so identical sheets dedupe and re-sharing +is idempotent. Payloads are stored deflated in a single SQLite file. + +### Config (env) + +| Var | Default | Notes | +| ------------- | ------------- | ------------------------------------------------ | +| `ADDR` | `:8090` | Listen address. The unit binds `127.0.0.1:8090`. | +| `DB_PATH` | `shares.db` | SQLite file path. | +| `CORS_ORIGIN` | _(unset)_ | Set to allow a cross-origin dev frontend. | + +## Deploy + +From the repo root: + +```sh +just deploy-server # build (linux/amd64) + ship + restart +just go-arch=arm64 deploy-server # if the host is arm64 +``` + +That installs `/usr/local/bin/share-svc` and `share-svc.service`, then +`daemon-reload` + `enable --now` + `restart`. The SQLite DB lives in +`/var/lib/share-svc/` (created by the unit's `StateDirectory`). + +### One-time Caddy setup + +Add the `/fabula/api/*` reverse-proxy block from `Caddyfile.snippet` to your site +block (before the static `/fabula/*` handler) and reload Caddy. + +## Local dev + +`webpack serve` (port 8080) does not serve `/api/*`, so `?s=` sharing falls back +to inline `?c=` links. To exercise short links locally, run the service and point +the frontend at it: + +```sh +CORS_ORIGIN=http://localhost:8080 go run ./server +``` diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..ad7475d --- /dev/null +++ b/server/go.mod @@ -0,0 +1,16 @@ +module github.com/drew/fabula-ultima-html/server + +go 1.26.4 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.52.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..0f031a3 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,21 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..17b9aba --- /dev/null +++ b/server/main.go @@ -0,0 +1,199 @@ +// Command share-svc is a tiny HTTP service that stores character-sheet JSON +// blobs and hands back short, content-addressed IDs so they can be shared via a +// compact URL (e.g. ...?s=a1b2c3d4) instead of stuffing the whole sheet into the +// query string. +// +// API: +// +// POST /api/s body: sheet JSON -> 200 {"id":""} +// GET /api/s/{id} -> 200 sheet JSON +// +// Storage is a single SQLite file; payloads are deflated on the way in and +// inflated on the way out. IDs are derived from the SHA-256 of the JSON, so +// posting the same sheet twice yields the same ID (free dedupe + idempotency). +package main + +import ( + "bytes" + "compress/flate" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "os" + "time" + + _ "modernc.org/sqlite" +) + +const ( + // idLen is how many base64url chars of the content hash form an ID. + // 12 chars ~= 72 bits, far more than enough to avoid collisions here. + idLen = 12 + // maxBody caps an accepted sheet payload (pre-compression). + maxBody = 512 << 10 // 512 KiB +) + +type store struct { + db *sql.DB +} + +func openStore(path string) (*store, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + // Single writer; WAL keeps reads fast and concurrent. + if _, err := db.Exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;`); err != nil { + return nil, err + } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS shares ( + id TEXT PRIMARY KEY, + data BLOB NOT NULL, + created INTEGER NOT NULL + )`); err != nil { + return nil, err + } + return &store{db: db}, nil +} + +// put stores raw (uncompressed) JSON bytes and returns its short ID. Storing the +// same bytes again is a no-op that returns the same ID. +func (s *store) put(raw []byte) (string, error) { + sum := sha256.Sum256(raw) + id := base64.RawURLEncoding.EncodeToString(sum[:])[:idLen] + + deflated, err := deflate(raw) + if err != nil { + return "", err + } + // INSERT OR IGNORE: a matching ID already holds identical content. + if _, err := s.db.Exec( + `INSERT OR IGNORE INTO shares (id, data, created) VALUES (?, ?, ?)`, + id, deflated, time.Now().Unix(), + ); err != nil { + return "", err + } + return id, nil +} + +// get returns the stored JSON bytes for an ID, or sql.ErrNoRows if unknown. +func (s *store) get(id string) ([]byte, error) { + var deflated []byte + err := s.db.QueryRow(`SELECT data FROM shares WHERE id = ?`, id).Scan(&deflated) + if err != nil { + return nil, err + } + return inflate(deflated) +} + +func deflate(b []byte) ([]byte, error) { + var buf bytes.Buffer + w, err := flate.NewWriter(&buf, flate.BestCompression) + if err != nil { + return nil, err + } + if _, err := w.Write(b); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func inflate(b []byte) ([]byte, error) { + r := flate.NewReader(bytes.NewReader(b)) + defer r.Close() + return io.ReadAll(r) +} + +func main() { + addr := envOr("ADDR", ":8090") + dbPath := envOr("DB_PATH", "shares.db") + corsOrigin := os.Getenv("CORS_ORIGIN") // empty = same-origin only + + st, err := openStore(dbPath) + if err != nil { + log.Fatalf("open store: %v", err) + } + + mux := http.NewServeMux() + mux.HandleFunc("POST /api/s", func(w http.ResponseWriter, r *http.Request) { + raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody)) + if err != nil { + http.Error(w, "payload too large", http.StatusRequestEntityTooLarge) + return + } + if !json.Valid(raw) { + http.Error(w, "body must be JSON", http.StatusBadRequest) + return + } + id, err := st.put(raw) + if err != nil { + log.Printf("put: %v", err) + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + writeJSON(w, map[string]string{"id": id}) + }) + mux.HandleFunc("GET /api/s/{id}", func(w http.ResponseWriter, r *http.Request) { + raw, err := st.get(r.PathValue("id")) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("get: %v", err) + http.Error(w, "storage error", http.StatusInternalServerError) + return + } + // Shares are immutable, so let clients/proxies cache aggressively. + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Content-Type", "application/json") + w.Write(raw) + }) + + handler := withCORS(corsOrigin, mux) + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + } + log.Printf("share-svc listening on %s (db=%s)", addr, dbPath) + log.Fatal(srv.ListenAndServe()) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +// withCORS optionally allows a cross-origin frontend (handy when webpack-dev +// runs on a different port than the API). When origin is empty it's a no-op. +func withCORS(origin string, next http.Handler) http.Handler { + if origin == "" { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/server/share-svc.service b/server/share-svc.service new file mode 100644 index 0000000..1f1731f --- /dev/null +++ b/server/share-svc.service @@ -0,0 +1,35 @@ +[Unit] +Description=Fabula Ultima share-link service +Documentation=https://git.illaoi.pro/drew/fabula-ultima-html +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/local/bin/share-svc +# Bind to loopback only; Caddy reverse-proxies public traffic to it. +Environment=ADDR=127.0.0.1:8090 +Environment=DB_PATH=/var/lib/share-svc/shares.db +Restart=on-failure +RestartSec=2 + +# Run as an ephemeral, unprivileged user. StateDirectory creates and chowns +# /var/lib/share-svc so the SQLite file persists across restarts. +DynamicUser=yes +StateDirectory=share-svc + +# Hardening +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 +RestrictNamespaces=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes + +[Install] +WantedBy=multi-user.target diff --git a/src/CharacterSheet.tsx b/src/CharacterSheet.tsx index ff305c2..bdc7944 100644 --- a/src/CharacterSheet.tsx +++ b/src/CharacterSheet.tsx @@ -133,22 +133,37 @@ const BLANK_BONDS: Bond[] = Array.from({ length: 6 }, () => ({ feelings: [], })); +// URL-safe base64 (RFC 4648 §5): avoids +, /, = so the result needs no +// percent-encoding in a query string. Accepts standard base64 on decode too, +// so links shared before this change still load. +function bytesToBase64Url(bytes: Uint8Array) { + let binary = ""; + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function base64UrlToBytes(b64: string) { + let std = b64.replace(/-/g, "+").replace(/_/g, "/"); + if (std.length % 4) std += "=".repeat(4 - (std.length % 4)); + return Uint8Array.from(atob(std), (c) => c.charCodeAt(0)); +} + async function compressToBase64(str: string) { const stream = new CompressionStream("deflate-raw"); const writer = stream.writable.getWriter(); writer.write(new TextEncoder().encode(str)); writer.close(); const buf = await new Response(stream.readable).arrayBuffer(); - const bytes = new Uint8Array(buf); - let binary = ""; - for (let i = 0; i < bytes.length; i++) - binary += String.fromCharCode(bytes[i]); - return btoa(binary); + return bytesToBase64Url(new Uint8Array(buf)); } async function decompressFromBase64(b64: string) { try { - const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const bytes = base64UrlToBytes(b64); const stream = new DecompressionStream("deflate-raw"); const writer = stream.writable.getWriter(); writer.write(bytes); @@ -160,6 +175,23 @@ async function decompressFromBase64(b64: string) { } } +// Drop empty strings and empty arrays before encoding: a sheet is mostly blank +// text fields, and applyData() restores any missing key to its default. +function pruneEmpty(data: Record) { + return Object.fromEntries( + Object.entries(data).filter( + ([, v]) => v !== "" && !(Array.isArray(v) && v.length === 0), + ), + ); +} + +// Resolve a share-service path relative to the current page so it works under +// any base path (e.g. /fabula/) where Caddy reverse-proxies /api/* to the +// backend. Returns e.g. https://host/fabula/api/s. +function apiURL(path: string) { + return new URL(path, window.location.href).toString(); +} + export default function CharacterSheet() { const [activeTab, setActiveTab] = useState("main"); const [urlMode, setUrlMode] = useState(false); @@ -372,8 +404,23 @@ export default function CharacterSheet() { // Init useEffect(() => { - const encoded = new URLSearchParams(window.location.search).get("c"); - if (encoded) { + const params = new URLSearchParams(window.location.search); + const shortId = params.get("s"); + const encoded = params.get("c"); + if (shortId) { + // Short link: fetch the sheet JSON from the share service. + fetch(apiURL("api/s/" + encodeURIComponent(shortId))) + .then((res) => { + if (!res.ok) throw new Error(String(res.status)); + return res.json(); + }) + .then((data) => { + applyData(data); + setUrlMode(true); + }) + .catch(() => tryAutoLoad()); + } else if (encoded) { + // Legacy/offline self-contained link with the data inline. decompressFromBase64(encoded) .then((json) => { applyData(JSON.parse(json)); @@ -452,13 +499,24 @@ export default function CharacterSheet() { ); const copyShareURL = useCallback(async () => { - const encoded = await compressToBase64(JSON.stringify(collectData())); - await navigator.clipboard.writeText( - window.location.origin + - window.location.pathname + - "?c=" + - encodeURIComponent(encoded), - ); + const json = JSON.stringify(pruneEmpty(collectData())); + const base = window.location.origin + window.location.pathname; + let shareURL: string; + try { + // Preferred: store the sheet server-side and share a short ?s= link. + const res = await fetch(apiURL("api/s"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: json, + }); + if (!res.ok) throw new Error(String(res.status)); + const { id } = await res.json(); + shareURL = base + "?s=" + id; + } catch { + // Backend unreachable: fall back to a self-contained inline ?c= link. + shareURL = base + "?c=" + (await compressToBase64(json)); + } + await navigator.clipboard.writeText(shareURL); setCopyStatus(true); setTimeout(() => setCopyStatus(false), 2000); }, [collectData]);