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