shorten-share-url #2

Merged
drew merged 5 commits from shorten-share-url into master 2026-06-16 23:25:22 -04:00
12 changed files with 532 additions and 21 deletions

View File

@@ -5,10 +5,23 @@
"resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a", "resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a",
"integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a" "integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a"
}, },
"ghcr.io/devcontainers/features/node:2.0.0": { "ghcr.io/devcontainers-community/npm-features/typescript:1": {
"version": "2.0.0", "version": "1.1.0",
"resolved": "ghcr.io/devcontainers/features/node@sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f", "resolved": "ghcr.io/devcontainers-community/npm-features/typescript@sha256:13a0f63e88513a6022431c39b7ca4ec732ba0760cdb6d882638f4ddf73deb0e7",
"integrity": "sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f" "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": { "ghcr.io/jsburckhardt/devcontainer-features/just:1": {
"version": "1.0.0", "version": "1.0.0",

View File

@@ -1,8 +1,11 @@
{ {
"image": "mcr.microsoft.com/devcontainers/base:ubuntu", "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": { "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/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": {}
} }
} }

View File

@@ -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
'

View File

@@ -6,8 +6,12 @@ user := "root"
host := "goldfish.malzahn.lan" host := "goldfish.malzahn.lan"
www-root := "/usr/share/caddy/public_html/fabula" 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: clean:
rm -rf dist/* rm -rf dist/*
rm -f server/share-svc
serve: serve:
npm run dev npm run dev
@@ -18,6 +22,16 @@ build:
deploy: build deploy: build
scp -r dist/* {{ user }}@{{ host }}:{{ www-root }}/ 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: format:
npx prettier --write books/ npx prettier --write books/
npx prettier --write src/ npx prettier --write src/

7
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Compiled binary
/share-svc
# SQLite database + WAL/SHM sidecars
*.db
*.db-wal
*.db-shm

21
server/Caddyfile.snippet Normal file
View File

@@ -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
# }

51
server/README.md Normal file
View File

@@ -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=<id>` instead of a giant
inline `?c=` payload.
## API
| Method | Path | Body | Response |
| ------ | ------------ | ----------- | ------------------- |
| POST | `/api/s` | sheet JSON | `{"id":"<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
```

16
server/go.mod Normal file
View File

@@ -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
)

21
server/go.sum Normal file
View File

@@ -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=

199
server/main.go Normal file
View File

@@ -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":"<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
}

35
server/share-svc.service Normal file
View File

@@ -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

View File

@@ -133,22 +133,37 @@ const BLANK_BONDS: Bond[] = Array.from({ length: 6 }, () => ({
feelings: [], 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) { async function compressToBase64(str: string) {
const stream = new CompressionStream("deflate-raw"); const stream = new CompressionStream("deflate-raw");
const writer = stream.writable.getWriter(); const writer = stream.writable.getWriter();
writer.write(new TextEncoder().encode(str)); writer.write(new TextEncoder().encode(str));
writer.close(); writer.close();
const buf = await new Response(stream.readable).arrayBuffer(); const buf = await new Response(stream.readable).arrayBuffer();
const bytes = new Uint8Array(buf); return bytesToBase64Url(new Uint8Array(buf));
let binary = "";
for (let i = 0; i < bytes.length; i++)
binary += String.fromCharCode(bytes[i]);
return btoa(binary);
} }
async function decompressFromBase64(b64: string) { async function decompressFromBase64(b64: string) {
try { try {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); const bytes = base64UrlToBytes(b64);
const stream = new DecompressionStream("deflate-raw"); const stream = new DecompressionStream("deflate-raw");
const writer = stream.writable.getWriter(); const writer = stream.writable.getWriter();
writer.write(bytes); 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<string, unknown>) {
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() { export default function CharacterSheet() {
const [activeTab, setActiveTab] = useState("main"); const [activeTab, setActiveTab] = useState("main");
const [urlMode, setUrlMode] = useState(false); const [urlMode, setUrlMode] = useState(false);
@@ -372,8 +404,23 @@ export default function CharacterSheet() {
// Init // Init
useEffect(() => { useEffect(() => {
const encoded = new URLSearchParams(window.location.search).get("c"); const params = new URLSearchParams(window.location.search);
if (encoded) { 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) decompressFromBase64(encoded)
.then((json) => { .then((json) => {
applyData(JSON.parse(json)); applyData(JSON.parse(json));
@@ -452,13 +499,24 @@ export default function CharacterSheet() {
); );
const copyShareURL = useCallback(async () => { const copyShareURL = useCallback(async () => {
const encoded = await compressToBase64(JSON.stringify(collectData())); const json = JSON.stringify(pruneEmpty(collectData()));
await navigator.clipboard.writeText( const base = window.location.origin + window.location.pathname;
window.location.origin + let shareURL: string;
window.location.pathname + try {
"?c=" + // Preferred: store the sheet server-side and share a short ?s=<id> link.
encodeURIComponent(encoded), 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); setCopyStatus(true);
setTimeout(() => setCopyStatus(false), 2000); setTimeout(() => setCopyStatus(false), 2000);
}, [collectData]); }, [collectData]);