feat: Add share-link backend and wire up the sheet
Add a small Go + SQLite service (server/) that stores character-sheet JSON blobs and returns short, content-addressed IDs, so sheets can be shared via a compact ?s=<id> link instead of an oversized inline payload. CharacterSheet.tsx now POSTs to the service on share and fetches by ID on load, falling back to the self-contained ?c= inline link when the backend is unreachable. Legacy ?c= links still decode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
7
server/.gitignore
vendored
Normal file
7
server/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Compiled binary
|
||||||
|
/share-svc
|
||||||
|
|
||||||
|
# SQLite database + WAL/SHM sidecars
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
16
server/go.mod
Normal file
16
server/go.mod
Normal 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
21
server/go.sum
Normal 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
199
server/main.go
Normal 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
|
||||||
|
}
|
||||||
@@ -185,6 +185,13 @@ function pruneEmpty(data: Record<string, unknown>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
@@ -397,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));
|
||||||
@@ -477,12 +499,24 @@ export default function CharacterSheet() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const copyShareURL = useCallback(async () => {
|
const copyShareURL = useCallback(async () => {
|
||||||
const encoded = await compressToBase64(
|
const json = JSON.stringify(pruneEmpty(collectData()));
|
||||||
JSON.stringify(pruneEmpty(collectData())),
|
const base = window.location.origin + window.location.pathname;
|
||||||
);
|
let shareURL: string;
|
||||||
await navigator.clipboard.writeText(
|
try {
|
||||||
window.location.origin + window.location.pathname + "?c=" + encoded,
|
// Preferred: store the sheet server-side and share a short ?s=<id> 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);
|
setCopyStatus(true);
|
||||||
setTimeout(() => setCopyStatus(false), 2000);
|
setTimeout(() => setCopyStatus(false), 2000);
|
||||||
}, [collectData]);
|
}, [collectData]);
|
||||||
|
|||||||
Reference in New Issue
Block a user