Files
fabula-ultima-html/server/main.go
Drew Malzahn 122a4cc881 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>
2026-06-17 01:44:15 +00:00

200 lines
5.3 KiB
Go

// 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
}