shorten-share-url #2
@@ -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",
|
||||||
|
|||||||
@@ -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": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
73
.gitea/workflows/deploy.yaml
Normal file
73
.gitea/workflows/deploy.yaml
Normal 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
|
||||||
|
'
|
||||||
14
Justfile
14
Justfile
@@ -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
7
server/.gitignore
vendored
Normal 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
21
server/Caddyfile.snippet
Normal 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
51
server/README.md
Normal 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
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
|
||||||
|
}
|
||||||
35
server/share-svc.service
Normal file
35
server/share-svc.service
Normal 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
|
||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user