server: rename the glue service to fetch (#1248)
'Glue' was the name for the whole supporting stack (router, relay, provisioner…), not one service — this one is the public-web FETCH service (Paul, 2026-07-22). Mechanical rename, no behaviour change: lchat fetch run, lchat-fetch.service, LCHAT_FETCH_URL / LCHAT_FETCH_DB, window.__fetchURL, fetch_previews, docs/fetch.md.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+152
−152
CLAUDE.md+2 −2bootstrap.go+5 −5deploy/systemd/{lchat-glue.service => lchat-fetch.service}+4 −4docs/{glue.md => fetch.md}+21 −21glue.go => fetch.go+62 −62glue_test.go => fetch_test.go+35 −35main.go+13 −13subcommands.go+1 −1web/pages/index.html+3 −3web/static/shell.js+6 −6
diff --git a/CLAUDE.md b/CLAUDE.md| index 93c5186..6ecf410 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -165,13 +165,13 @@ in, so deploy is one file (no cgo). |
| Stood up per instance by `lchat add-hooks`. Design + trust model: |
| `docs/webhooks.md`; management UI `web/static/apps/webhooks.js` (admin-only). |
| - `unfurl.go` — OpenGraph link previews (SSRF-guarded fetch + cache). |
| -- `glue.go` — `lchat glue run`, the **public-web fetch glue**: a shared, |
| +- `fetch.go` — `lchat fetch run`, the **public-web fetch service**: a shared, |
| identity-free service (unfurl + camo-style HMAC-signed image proxy) that |
| fronts public web content for clients, so eventually the *instance* never |
| sees URLs and card-image hosts never see member IPs. Reuses unfurl.go's |
| fetch/SSRF/sealed-cache machinery; sees URLs attached to nothing; member |
| content categorically out of scope. Design + trust model + roadmap |
| - (client flip, resize, Giphy-with-our-key): `docs/glue.md`. |
| + (client flip, resize, Giphy-with-our-key): `docs/fetch.md`. |
| - `linkscmd.go` — `lchat links run`, the `11ch.at` short-link redirector for |
| message permalinks (issue #905): a bare apex request bounces to |
| `elevenmessenger.com`; `/l/<host>` live-checks the target (SSRF-guarded fetch |
diff --git a/bootstrap.go b/bootstrap.go| index 49b53c4..acf9136 100644 |
| --- a/bootstrap.go |
| +++ b/bootstrap.go |
| @@ -78,12 +78,12 @@ var unitCloud string |
| //go:embed deploy/systemd/lchat-tunnel.service |
| var unitTunnel string |
| -// The public-web fetch glue (`lchat glue run` — see docs/glue.md). Installs |
| +// The public-web fetch service (`lchat fetch run` — see docs/fetch.md). Installs |
| // everywhere, stays inert until an operator enables it and fronts it with |
| -// `add-service`; instances opt in by pointing LCHAT_GLUE_URL at that route. |
| +// `add-service`; instances opt in by pointing LCHAT_FETCH_URL at that route. |
| // |
| -//go:embed deploy/systemd/lchat-glue.service |
| -var unitGlue string |
| +//go:embed deploy/systemd/lchat-fetch.service |
| +var unitFetch string |
| // The auto-updater (updater.go): a root oneshot fired hourly by its timer. |
| // Enabled by bootstrap — a pass with no reachable release feed is a no-op, so |
| @@ -503,7 +503,7 @@ func runBootstrap(args []string) { |
| write(filepath.Join(*unitDir, "lchat-control.service"), unitControl) |
| write(filepath.Join(*unitDir, "lchat-cloud.service"), unitCloud) |
| write(filepath.Join(*unitDir, "lchat-tunnel.service"), unitTunnel) |
| - write(filepath.Join(*unitDir, "lchat-glue.service"), unitGlue) |
| + write(filepath.Join(*unitDir, "lchat-fetch.service"), unitFetch) |
| write(filepath.Join(*unitDir, "lchat-update.service"), unitUpdate) |
| write(filepath.Join(*unitDir, "lchat-update.timer"), unitUpdateTimer) |
| write("/etc/tmpfiles.d/lchat.conf", tmpfilesConf) |
diff --git a/deploy/systemd/lchat-glue.service b/deploy/systemd/lchat-fetch.service| similarity index 71% |
| rename from deploy/systemd/lchat-glue.service |
| rename to deploy/systemd/lchat-fetch.service |
| index 7f066a1..c2496d9 100644 |
| --- a/deploy/systemd/lchat-glue.service |
| +++ b/deploy/systemd/lchat-fetch.service |
| @@ -1,12 +1,12 @@ |
| [Unit] |
| -Description=lchat public-web fetch glue (unfurl + signed image proxy) |
| +Description=lchat public-web fetch service (unfurl + signed image proxy) |
| After=network-online.target |
| Wants=network-online.target |
| [Service] |
| -# No env file: the glue's secrets (preview salt + image-signing key) mint |
| -# themselves into its DB on first boot (glue.go); nothing else to configure. |
| -ExecStart=/opt/lchat/lchat glue run --socket /run/lchat/glue.sock --db /data/glue.db |
| +# No env file: the service's secrets (preview salt + image-signing key) mint |
| +# themselves into its DB on first boot (fetch.go); nothing else to configure. |
| +ExecStart=/opt/lchat/lchat fetch run --socket /run/lchat/fetch.sock --db /data/fetch.db |
| Restart=always |
| RestartSec=1 |
diff --git a/docs/glue.md b/docs/fetch.md| similarity index 72% |
| rename from docs/glue.md |
| rename to docs/fetch.md |
| index d250a34..d280265 100644 |
| --- a/docs/glue.md |
| +++ b/docs/fetch.md |
| @@ -1,8 +1,8 @@ |
| -# 🤖 The public-web fetch glue (`lchat glue run`) |
| +# 🤖 The public-web fetch service (`lchat fetch run`) |
| A shared, identity-free service that fronts **public web content** for |
| clients: OpenGraph unfurls and a camo-style signed image proxy for the cards' |
| -images. One binary subcommand (`glue.go`), one sqlite file, fronted by the |
| +images. One binary subcommand (`fetch.go`), one sqlite file, fronted by the |
| instance router like links/push/home. Born in the unfurl-through-Finland |
| discussion (PRs #1237/#1240 → this): if a service is going to see posted |
| URLs at all, it should be a service that knows *nothing else*. |
| @@ -11,22 +11,22 @@ URLs at all, it should be a service that knows *nothing else*. |
| Unfurling is the messenger's one documented E2E exception: to build a link |
| card, *some* server must see the URL. Today that server is the member's own |
| -instance. Moving the fetch to shared glue buys, in rough order of value: |
| +instance. Moving the fetch to the shared service buys, in rough order of value: |
| -1. **The instance can stop seeing URLs entirely** (once clients call the glue |
| +1. **The instance can stop seeing URLs entirely** (once clients call the service |
| directly — the phase-2 flip below). The instance server then stores and |
| relays only ciphertext, full stop; the unfurl exception moves to a service |
| that holds no members, threads, tokens, or instance list. |
| 2. **Viewers stop leaking their IPs to card-image hosts.** The web client |
| sets `img.src` to the raw `og:image` URL today, so every *viewer* of a |
| card fetches from a third-party host; `http:`-only images die silently as |
| - mixed content. The glue's `/img` proxy fixes both. |
| + mixed content. Its `/img` proxy fixes both. |
| 3. **Self-hosters stop exposing their egress IP.** A member can post a link |
| to a server they control and learn the instance's IP — for a home |
| - self-hoster, their home IP. Pointing unfurl at the glue hides it; running |
| - their own glue keeps full autonomy. |
| + self-hoster, their home IP. Pointing unfurl at the shared service hides it; running |
| + their own keeps full autonomy. |
| 4. **A shared warm cache.** Popular links unfurl once per language across |
| - every space that uses the glue, and the poster/recipient in-flight window |
| + every space that uses the service, and the poster/recipient in-flight window |
| mostly disappears because the cache is usually already warm. |
| ## Trust model |
| @@ -37,14 +37,14 @@ instance. Moving the fetch to shared glue buys, in rough order of value: |
| - **Stores:** the sealed preview cache — same convergent-encryption scheme as |
| the instance's (`unfurl.go`): row key = salted hash of language+URL, blob |
| opens only with a key derived from language+URL, service-wide salt (minted |
| - on first boot, lives only in the glue's DB) defeats precomputed |
| + on first boot, lives only in the service's DB) defeats precomputed |
| dictionaries. The cache at rest reveals nothing a reader doesn't already |
| know the URL for. No request logs; the only log line is startup. |
| - **Never:** member content. Member photos/files are encrypted client-side |
| (`processImage` + `encryptBytes`) and are categorically out of scope — the |
| - glue's remit is content already on the public web. Any future endpoint that |
| + its remit is content already on the public web. Any future endpoint that |
| would touch member bytes is a design error; say no. |
| -- **Not an open proxy:** `/img` serves only URLs the glue itself HMAC-signed |
| +- **Not an open proxy:** `/img` serves only URLs the service itself HMAC-signed |
| while building a card (GitHub-camo shape), so third parties can't drive it |
| as a generic fetcher. Signing is at response time; the cache stores raw |
| URLs, so the key could rotate without invalidating cached cards. |
| @@ -56,33 +56,33 @@ instance. Moving the fetch to shared glue buys, in rough order of value: |
| `/api/unfurl` and `/img` carry per-IP token buckets, which behind the router |
| collapse into global buckets (no forwarded IPs — a privacy feature with a |
| -rate-limiting cost). That is acceptable while nothing points at the glue by |
| +rate-limiting cost). That is acceptable while nothing points at it by |
| default. Before the client flip makes it load-bearing, add **hashcash** (the |
| cloud provisioner already ships the machinery) so anonymous callers pay in |
| CPU instead of identity. |
| ## Deployment |
| -Standard sibling-service shape: `lchat glue run` behind the router on a unix |
| -socket (`-socket /run/lchat/glue.sock`), DB at `/data/glue.db` |
| -(`LCHAT_GLUE_DB`), service route + `/etc/hosts` pin like cloud/home. Not |
| +Standard sibling-service shape: `lchat fetch run` behind the router on a unix |
| +socket (`-socket /run/lchat/fetch.sock`), DB at `/data/fetch.db` |
| +(`LCHAT_FETCH_DB`), service route + `/etc/hosts` pin like cloud/home. Not |
| deployed anywhere yet; when it ships, the planet-scale guide gains its row. |
| No secrets in env — the salt and signing key mint themselves into the DB on |
| first boot. |
| ## Roadmap |
| -- **Phase 2 — the client flip.** Instance serves the glue URL to clients |
| +- **Phase 2 — the client flip.** Instance serves the fetch URL to clients |
| (config), CSP grows `connect-src`/`img-src` for it, `host.unfurl` prefers |
| - the glue with instance-unfurl fallback, and hashcash lands with it. |
| + the fetch service with instance-unfurl fallback, and hashcash lands with it. |
| Self-host default stays *local fetch* — a self-hosted instance silently |
| - shipping its members' URLs to our glue would betray the self-host story; |
| - pointing at shared glue (or their own) is opt-in config. |
| + shipping its members' URLs to our shared service would betray the self-host story; |
| + pointing at the shared service (or their own) is opt-in config. |
| - **Resize.** Public card images only (og:image can be multi-MB); a flag on |
| the `/img` path, never a new trust decision. Member photos keep their |
| client-side pipeline. |
| - **Giphy.** Same service family, authenticated upstream: search rides the |
| - glue with *our* API key (kills the get-an-API-key wall for spaces), the |
| + the fetch service with *our* API key (kills the get-an-API-key wall for spaces), the |
| space-supplied key remains as override (docs/app-credentials.md). To be |
| honest about the trade: today Giphy search is client→Giphy (we see |
| - nothing); via glue we see search terms, identity-free. |
| + nothing); via the service we see search terms, identity-free. |
diff --git a/glue.go b/fetch.go| similarity index 73% |
| rename from glue.go |
| rename to fetch.go |
| index b0c731b..a91291a 100644 |
| --- a/glue.go |
| +++ b/fetch.go |
| @@ -1,15 +1,15 @@ |
| -// `lchat glue run` — the public-web fetch glue (docs/glue.md). A shared, |
| +// `lchat fetch run` — the public-web fetch service (docs/fetch.md). A shared, |
| // identity-free service that fronts *public web content* for clients: OpenGraph |
| // unfurls and an HMAC-signed image proxy for the cards' images. Like links |
| // (linkscmd.go) it's a subcommand of this binary, not a separate module — |
| // it deliberately reuses the instance's own unfurl machinery (unfurl.go: |
| // fetchOG, the SSRF dial guard, the sealed convergent cache, previewFlight). |
| // |
| -// Trust model, in one breath: the glue sees URLs (the documented unfurl |
| +// Trust model, in one breath: the fetch service sees URLs (the documented unfurl |
| // exception) attached to NOTHING — no token, no instance, no member. Behind |
| // the router it doesn't even see client IPs (nothing forwards them — that's a |
| // feature, with the honest cost that per-IP rate buckets collapse to global |
| -// ones there; hashcash is the planned real gate, see docs/glue.md). It must |
| +// ones there; hashcash is the planned real gate, see docs/fetch.md). It must |
| // never grow an endpoint that touches member content: member photos/files are |
| // encrypted client-side and are categorically out of scope here. |
| // |
| @@ -41,22 +41,22 @@ import ( |
| ) |
| const ( |
| - glueImgMaxBytes = 8 * 1024 * 1024 // largest card image we'll proxy |
| - glueImgTimeout = 15 * time.Second |
| - glueImgCache = "public, max-age=86400" |
| + fetchImgMaxBytes = 8 * 1024 * 1024 // largest card image we'll proxy |
| + fetchImgTimeout = 15 * time.Second |
| + fetchImgCache = "public, max-age=86400" |
| ) |
| -// glueImgClient fetches card images. Same SSRF posture as ogClient — every |
| +// fetchImgClient fetches card images. Same SSRF posture as ogClient — every |
| // dial (and every redirect hop) re-validates the resolved IP. |
| -var glueImgClient = &http.Client{ |
| - Timeout: glueImgTimeout, |
| +var fetchImgClient = &http.Client{ |
| + Timeout: fetchImgTimeout, |
| Transport: &http.Transport{ |
| DialContext: (&net.Dialer{ |
| - Timeout: glueImgTimeout, |
| + Timeout: fetchImgTimeout, |
| Control: guardDialControl, |
| }).DialContext, |
| - TLSHandshakeTimeout: glueImgTimeout, |
| - ResponseHeaderTimeout: glueImgTimeout, |
| + TLSHandshakeTimeout: fetchImgTimeout, |
| + ResponseHeaderTimeout: fetchImgTimeout, |
| DisableKeepAlives: true, |
| }, |
| CheckRedirect: func(req *http.Request, via []*http.Request) error { |
| @@ -70,7 +70,7 @@ var glueImgClient = &http.Client{ |
| }, |
| } |
| -type glueServer struct { |
| +type fetchServer struct { |
| db *sql.DB |
| salt string // service-wide preview salt (convergent-encryption pepper) |
| signKey []byte // HMAC key for /img URLs; minted once, lives only in the DB |
| @@ -83,7 +83,7 @@ type glueServer struct { |
| imgLimiter *modLimiter |
| } |
| -func newGlueServer(dbPath string) (*glueServer, error) { |
| +func newFetchServer(dbPath string) (*fetchServer, error) { |
| d, err := sql.Open("sqlite", dbPath) |
| if err != nil { |
| return nil, err |
| @@ -104,7 +104,7 @@ func newGlueServer(dbPath string) (*glueServer, error) { |
| // is a salted hash and the blob opens only with a key derived from the |
| // language+URL, so the shared cache at rest reveals nothing a reader |
| // doesn't already know the URL for. |
| - `CREATE TABLE IF NOT EXISTS glue_previews( |
| + `CREATE TABLE IF NOT EXISTS fetch_previews( |
| url_hash TEXT PRIMARY KEY, |
| blob TEXT NOT NULL, |
| fetched_at INTEGER NOT NULL)`, |
| @@ -113,15 +113,15 @@ func newGlueServer(dbPath string) (*glueServer, error) { |
| return nil, err |
| } |
| } |
| - g := &glueServer{ |
| + g := &fetchServer{ |
| db: d, |
| unfurlLimiter: newModLimiter(30, 30.0/60), |
| imgLimiter: newModLimiter(240, 120.0/60), |
| } |
| - if g.salt, err = glueConfigSecret(d, "preview_salt"); err != nil { |
| + if g.salt, err = fetchConfigSecret(d, "preview_salt"); err != nil { |
| return nil, err |
| } |
| - keyHex, err := glueConfigSecret(d, "img_sign_key") |
| + keyHex, err := fetchConfigSecret(d, "img_sign_key") |
| if err != nil { |
| return nil, err |
| } |
| @@ -131,10 +131,10 @@ func newGlueServer(dbPath string) (*glueServer, error) { |
| return g, nil |
| } |
| -// glueConfigSecret returns the named config value, minting a random one on |
| +// fetchConfigSecret returns the named config value, minting a random one on |
| // first boot — the same insert-if-absent pattern as the instance's |
| // previewSalt, so restarts and concurrent starts converge on one value. |
| -func glueConfigSecret(d *sql.DB, key string) (string, error) { |
| +func fetchConfigSecret(d *sql.DB, key string) (string, error) { |
| buf := make([]byte, 32) |
| if _, err := rand.Read(buf); err != nil { |
| return "", err |
| @@ -148,7 +148,7 @@ func glueConfigSecret(d *sql.DB, key string) (string, error) { |
| return v, err |
| } |
| -func (g *glueServer) handler() http.Handler { |
| +func (g *fetchServer) handler() http.Handler { |
| mux := http.NewServeMux() |
| mux.HandleFunc("GET /api/unfurl", g.serveUnfurl) |
| mux.HandleFunc("GET /img", g.serveImage) |
| @@ -156,19 +156,19 @@ func (g *glueServer) handler() http.Handler { |
| fmt.Fprintln(w, "ok") |
| }) |
| mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) { |
| - writeJSON(w, http.StatusOK, map[string]any{"service": "glue", "build": buildVersion}) |
| + writeJSON(w, http.StatusOK, map[string]any{"service": "fetch", "build": buildVersion}) |
| }) |
| - // The glue has no pages of its own; a stray visit goes to the real site. |
| + // The fetch service has no pages of its own; a stray visit goes to the real site. |
| mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "https://elevenmessenger.com", http.StatusFound) |
| }) |
| return mux |
| } |
| -// glueCallerKey is the rate-limit bucket key. Mirrors linkscmd.go — behind |
| +// fetchCallerKey is the rate-limit bucket key. Mirrors linkscmd.go — behind |
| // the router (unix socket, no forwarded IPs) this degrades to one shared |
| // bucket, which is the accepted posture for now (package doc). |
| -func glueCallerKey(r *http.Request) string { |
| +func fetchCallerKey(r *http.Request) string { |
| ip, _, err := net.SplitHostPort(r.RemoteAddr) |
| if err != nil { |
| return r.RemoteAddr |
| @@ -179,8 +179,8 @@ func glueCallerKey(r *http.Request) string { |
| // serveUnfurl is the instance handler's anonymous twin: no token, same URL |
| // validation, same language handling, same stale-serve semantics — but the |
| // card's image URL is rewritten to the signed /img proxy before it leaves. |
| -func (g *glueServer) serveUnfurl(w http.ResponseWriter, r *http.Request) { |
| - if !g.unfurlLimiter.allow(glueCallerKey(r)) { |
| +func (g *fetchServer) serveUnfurl(w http.ResponseWriter, r *http.Request) { |
| + if !g.unfurlLimiter.allow(fetchCallerKey(r)) { |
| httpError(w, http.StatusTooManyRequests, "slow down") |
| return |
| } |
| @@ -208,8 +208,8 @@ func (g *glueServer) serveUnfurl(w http.ResponseWriter, r *http.Request) { |
| } |
| // fetchAndCache mirrors the instance's fetchAndCachePreview (main.go), |
| -// including the mid-fetch waiters fix — against the glue's own store. |
| -func (g *glueServer) fetchAndCache(norm, lang string) LinkPreview { |
| +// including the mid-fetch waiters fix — against the fetch service's own store. |
| +func (g *fetchServer) fetchAndCache(norm, lang string) LinkPreview { |
| flightKey := previewCacheKey(lang, norm) |
| f := &previewFlight{done: make(chan struct{})} |
| if cur, busy := g.inflight.LoadOrStore(flightKey, f); busy { |
| @@ -233,12 +233,12 @@ func (g *glueServer) fetchAndCache(norm, lang string) LinkPreview { |
| return pv |
| } |
| -func (g *glueServer) getPreview(u, lang string) (LinkPreview, bool) { |
| +func (g *fetchServer) getPreview(u, lang string) (LinkPreview, bool) { |
| key := previewCacheKey(lang, u) |
| var blob string |
| var fetchedAt int64 |
| err := g.db.QueryRow( |
| - "SELECT blob, fetched_at FROM glue_previews WHERE url_hash = ?", previewURLHash(g.salt, key), |
| + "SELECT blob, fetched_at FROM fetch_previews WHERE url_hash = ?", previewURLHash(g.salt, key), |
| ).Scan(&blob, &fetchedAt) |
| if err != nil { |
| return LinkPreview{}, false |
| @@ -252,35 +252,35 @@ func (g *glueServer) getPreview(u, lang string) (LinkPreview, bool) { |
| return pv, true |
| } |
| -func (g *glueServer) putPreview(pv LinkPreview, lang string) { |
| +func (g *fetchServer) putPreview(pv LinkPreview, lang string) { |
| key := previewCacheKey(lang, pv.URL) |
| blob, err := sealPreview(g.salt, key, pv) |
| if err != nil { |
| return |
| } |
| // Same put-time TTL sweep as the instance cache: prunes with activity. |
| - g.db.Exec("DELETE FROM glue_previews WHERE fetched_at < ?", time.Now().Unix()-previewMaxAge) |
| + g.db.Exec("DELETE FROM fetch_previews WHERE fetched_at < ?", time.Now().Unix()-previewMaxAge) |
| g.db.Exec( |
| - `INSERT INTO glue_previews(url_hash, blob, fetched_at) VALUES(?, ?, ?) |
| + `INSERT INTO fetch_previews(url_hash, blob, fetched_at) VALUES(?, ?, ?) |
| ON CONFLICT(url_hash) DO UPDATE SET blob=excluded.blob, fetched_at=excluded.fetched_at`, |
| previewURLHash(g.salt, key), blob, pv.FetchedAt) |
| } |
| // signedCard swaps a card's raw og:image URL for the signed /img proxy URL, |
| -// so the viewer's browser talks to the glue, not the image's host (no member |
| +// so the viewer's browser talks to the fetch service, not the image's host (no member |
| // IP reaches it, and http-only images stop dying as mixed content). |
| -func (g *glueServer) signedCard(r *http.Request, pv LinkPreview) LinkPreview { |
| +func (g *fetchServer) signedCard(r *http.Request, pv LinkPreview) LinkPreview { |
| if pv.Image != "" { |
| pv.Image = g.proxyImageURL(r, pv.Image) |
| } |
| return pv |
| } |
| -func (g *glueServer) proxyImageURL(r *http.Request, img string) string { |
| +func (g *fetchServer) proxyImageURL(r *http.Request, img string) string { |
| mac := hmac.New(sha256.New, g.signKey) |
| mac.Write([]byte(img)) |
| scheme := "https" |
| - if r.TLS == nil && glueLoopbackHost(r.Host) { |
| + if r.TLS == nil && fetchLoopbackHost(r.Host) { |
| scheme = "http" // tests and bare local runs; behind the router TLS is the router's |
| } |
| return scheme + "://" + r.Host + "/img?u=" + |
| @@ -288,7 +288,7 @@ func (g *glueServer) proxyImageURL(r *http.Request, img string) string { |
| "&s=" + hex.EncodeToString(mac.Sum(nil)) |
| } |
| -func glueLoopbackHost(hostport string) bool { |
| +func fetchLoopbackHost(hostport string) bool { |
| host, _, err := net.SplitHostPort(hostport) |
| if err != nil { |
| host = hostport |
| @@ -300,13 +300,13 @@ func glueLoopbackHost(hostport string) bool { |
| return ip != nil && ip.IsLoopback() |
| } |
| -// serveImage is the camo half: verify the signature (only URLs this glue |
| +// serveImage is the camo half: verify the signature (only URLs this service |
| // itself put in a card are proxyable — never an open proxy), then fetch and |
| // re-serve the image over our origin. Buffered rather than streamed so an |
| // oversize image is rejected cleanly instead of truncated mid-body; the size |
| // cap bounds the memory. |
| -func (g *glueServer) serveImage(w http.ResponseWriter, r *http.Request) { |
| - if !g.imgLimiter.allow(glueCallerKey(r)) { |
| +func (g *fetchServer) serveImage(w http.ResponseWriter, r *http.Request) { |
| + if !g.imgLimiter.allow(fetchCallerKey(r)) { |
| httpError(w, http.StatusTooManyRequests, "slow down") |
| return |
| } |
| @@ -337,9 +337,9 @@ func (g *glueServer) serveImage(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadGateway, "fetch failed") |
| return |
| } |
| - req.Header.Set("User-Agent", "lchat-glue/1.0 (+https://elevenmessenger.com)") |
| + req.Header.Set("User-Agent", "lchat-fetch/1.0 (+https://elevenmessenger.com)") |
| req.Header.Set("Accept", "image/*") |
| - resp, err := glueImgClient.Do(req) |
| + resp, err := fetchImgClient.Do(req) |
| if err != nil { |
| httpError(w, http.StatusBadGateway, "fetch failed") |
| return |
| @@ -350,52 +350,52 @@ func (g *glueServer) serveImage(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadGateway, "not an image") |
| return |
| } |
| - body, err := io.ReadAll(io.LimitReader(resp.Body, glueImgMaxBytes+1)) |
| - if err != nil || len(body) > glueImgMaxBytes { |
| + body, err := io.ReadAll(io.LimitReader(resp.Body, fetchImgMaxBytes+1)) |
| + if err != nil || len(body) > fetchImgMaxBytes { |
| httpError(w, http.StatusBadGateway, "image too large") |
| return |
| } |
| w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) |
| - w.Header().Set("Cache-Control", glueImgCache) |
| + w.Header().Set("Cache-Control", fetchImgCache) |
| w.Header().Set("X-Content-Type-Options", "nosniff") |
| w.Write(body) |
| } |
| -// runGlueCmd dispatches `lchat glue <command>`, mirroring runLinksCmd. |
| -func runGlueCmd(args []string) { |
| +// runFetchCmd dispatches `lchat fetch <command>`, mirroring runLinksCmd. |
| +func runFetchCmd(args []string) { |
| if len(args) < 1 { |
| - fmt.Fprintln(os.Stderr, `usage: lchat glue <command> |
| + fmt.Fprintln(os.Stderr, `usage: lchat fetch <command> |
| commands: |
| - run serve the public-web fetch glue (unfurl + signed image proxy; docs/glue.md)`) |
| + run serve the public-web fetch service (unfurl + signed image proxy; docs/fetch.md)`) |
| os.Exit(2) |
| } |
| switch args[0] { |
| case "run": |
| - runGlueRun(args[1:]) |
| + runFetchRun(args[1:]) |
| default: |
| - die("glue: unknown command %q", args[0]) |
| + die("fetch: unknown command %q", args[0]) |
| } |
| } |
| -// runGlueRun is `lchat glue run`. |
| -func runGlueRun(args []string) { |
| - fs := flag.NewFlagSet("glue", flag.ExitOnError) |
| +// runFetchRun is `lchat fetch run`. |
| +func runFetchRun(args []string) { |
| + fs := flag.NewFlagSet("fetch", flag.ExitOnError) |
| addr := fs.String("addr", envOr("ADDR", ":1749"), "listen address (env: ADDR)") |
| socket := fs.String("socket", envOr("LCHAT_SOCKET", ""), "unix socket to serve on instead of -addr (env: LCHAT_SOCKET) — how the router fronts this service") |
| - dbPath := fs.String("db", envOr("LCHAT_GLUE_DB", "glue.db"), "sqlite path for the sealed preview cache + service secrets (env: LCHAT_GLUE_DB)") |
| + dbPath := fs.String("db", envOr("LCHAT_FETCH_DB", "fetch.db"), "sqlite path for the sealed preview cache + service secrets (env: LCHAT_FETCH_DB)") |
| _ = fs.Parse(args) |
| - g, err := newGlueServer(*dbPath) |
| + g, err := newFetchServer(*dbPath) |
| if err != nil { |
| - die("glue: %v", err) |
| + die("fetch: %v", err) |
| } |
| ln, addrDesc, err := listen(*addr, *socket) |
| if err != nil { |
| - die("glue: %v", err) |
| + die("fetch: %v", err) |
| } |
| - // Deliberately the only startup log line — the glue never logs URLs. |
| - log.Printf("glue: serving on %s", addrDesc) |
| + // Deliberately the only startup log line — the fetch service never logs URLs. |
| + log.Printf("fetch: serving on %s", addrDesc) |
| srv := &http.Server{Handler: g.handler(), ReadHeaderTimeout: 10 * time.Second} |
| if err := srv.Serve(ln); err != nil { |
| log.Fatal(err) |
diff --git a/glue_test.go b/fetch_test.go| similarity index 71% |
| rename from glue_test.go |
| rename to fetch_test.go |
| index f8724c7..5c92f75 100644 |
| --- a/glue_test.go |
| +++ b/fetch_test.go |
| @@ -10,11 +10,11 @@ import ( |
| "testing" |
| ) |
| -func newTestGlue(t *testing.T) (*httptest.Server, *glueServer) { |
| +func newTestFetch(t *testing.T) (*httptest.Server, *fetchServer) { |
| t.Helper() |
| - g, err := newGlueServer(t.TempDir() + "/glue.db") |
| + g, err := newFetchServer(t.TempDir() + "/fetch.db") |
| if err != nil { |
| - t.Fatalf("newGlueServer: %v", err) |
| + t.Fatalf("newFetchServer: %v", err) |
| } |
| ts := httptest.NewServer(g.handler()) |
| t.Cleanup(ts.Close) |
| @@ -22,11 +22,11 @@ func newTestGlue(t *testing.T) (*httptest.Server, *glueServer) { |
| return ts, g |
| } |
| -// The glue unfurls anonymously (no token — it has no members to know about), |
| +// The fetch service unfurls anonymously (no token — it has no members to know about), |
| // rewrites the card's image to its signed /img proxy, and that proxy URL |
| -// actually serves the image bytes back over the glue's own origin. |
| -func TestGlueUnfurlAndImageProxy(t *testing.T) { |
| - ts, g := newTestGlue(t) |
| +// actually serves the image bytes back over the fetch service's own origin. |
| +func TestFetchUnfurlAndImageProxy(t *testing.T) { |
| + ts, g := newTestFetch(t) |
| allowPrivatePreviewHosts = true |
| t.Cleanup(func() { allowPrivatePreviewHosts = false }) |
| @@ -39,7 +39,7 @@ func TestGlueUnfurlAndImageProxy(t *testing.T) { |
| return |
| } |
| w.Header().Set("Content-Type", "text/html") |
| - w.Write([]byte(`<head><meta property="og:title" content="Glued">` + |
| + w.Write([]byte(`<head><meta property="og:title" content="Fetched">` + |
| `<meta property="og:image" content="/pic.png"></head>`)) |
| })) |
| t.Cleanup(site.Close) |
| @@ -51,10 +51,10 @@ func TestGlueUnfurlAndImageProxy(t *testing.T) { |
| var pv LinkPreview |
| json.NewDecoder(r.Body).Decode(&pv) |
| r.Body.Close() |
| - if !pv.OK || pv.Title != "Glued" { |
| + if !pv.OK || pv.Title != "Fetched" { |
| t.Fatalf("preview = %+v", pv) |
| } |
| - // The image is no longer the site's URL — it's the glue's signed proxy. |
| + // The image is no longer the site's URL — it's the fetch service's signed proxy. |
| if !strings.HasPrefix(pv.Image, ts.URL+"/img?") { |
| t.Fatalf("image not proxied: %q", pv.Image) |
| } |
| @@ -74,10 +74,10 @@ func TestGlueUnfurlAndImageProxy(t *testing.T) { |
| // Cached: a second unfurl works with the cache row (and stays sealed at |
| // rest — no plaintext in the stored row). |
| var urlHash, blob string |
| - if err := g.db.QueryRow("SELECT url_hash, blob FROM glue_previews").Scan(&urlHash, &blob); err != nil { |
| + if err := g.db.QueryRow("SELECT url_hash, blob FROM fetch_previews").Scan(&urlHash, &blob); err != nil { |
| t.Fatalf("raw row: %v", err) |
| } |
| - for _, secret := range []string{site.URL, "Glued", "pic.png"} { |
| + for _, secret := range []string{site.URL, "Fetched", "pic.png"} { |
| if strings.Contains(urlHash+blob, secret) { |
| t.Fatalf("plaintext %q found in the stored row", secret) |
| } |
| @@ -85,10 +85,10 @@ func TestGlueUnfurlAndImageProxy(t *testing.T) { |
| } |
| // /img is not an open proxy: an unsigned or tampered URL never fetches. |
| -func TestGlueImageProxyRejectsUnsigned(t *testing.T) { |
| - ts, g := newTestGlue(t) |
| +func TestFetchImageProxyRejectsUnsigned(t *testing.T) { |
| + ts, g := newTestFetch(t) |
| - // A URL the glue never signed: correct encoding, wrong signature. |
| + // A URL the fetch service never signed: correct encoding, wrong signature. |
| u := "aHR0cDovL2V4YW1wbGUuY29tL3guanBn" // base64url("http://example.com/x.jpg") |
| for _, q := range []string{ |
| "u=" + u + "&s=" + strings.Repeat("00", 32), |
| @@ -125,10 +125,10 @@ func TestGlueImageProxyRejectsUnsigned(t *testing.T) { |
| } |
| } |
| -// Language variants cache as separate rows on the glue too — same contract |
| +// Language variants cache as separate rows on the fetch service too — same contract |
| // as the instance (TestUnfurlLanguage), shared-cache edition. |
| -func TestGlueUnfurlLanguageVariants(t *testing.T) { |
| - ts, g := newTestGlue(t) |
| +func TestFetchUnfurlLanguageVariants(t *testing.T) { |
| + ts, g := newTestFetch(t) |
| allowPrivatePreviewHosts = true |
| t.Cleanup(func() { allowPrivatePreviewHosts = false }) |
| @@ -163,16 +163,16 @@ func TestGlueUnfurlLanguageVariants(t *testing.T) { |
| t.Fatalf("en-IE = %q", got) |
| } |
| var rows int |
| - g.db.QueryRow("SELECT COUNT(*) FROM glue_previews").Scan(&rows) |
| + g.db.QueryRow("SELECT COUNT(*) FROM fetch_previews").Scan(&rows) |
| if rows != 2 { |
| t.Fatalf("cache rows = %d, want 2", rows) |
| } |
| } |
| -// The client flip is env-gated: with LCHAT_GLUE_URL set, the chat page names |
| -// the glue in connect-src and hands the origin to shell.js; unset, the page |
| +// The client flip is env-gated: with LCHAT_FETCH_URL set, the chat page names |
| +// the fetch service in connect-src and hands the origin to shell.js; unset, the page |
| // is byte-identical to today's (self-host default = local unfurl). |
| -func TestGlueClientFlipInjection(t *testing.T) { |
| +func TestFetchClientFlipInjection(t *testing.T) { |
| ts, srv := newTestServer(t) |
| get := func() (csp, body string) { |
| @@ -187,41 +187,41 @@ func TestGlueClientFlipInjection(t *testing.T) { |
| } |
| csp, body := get() |
| - if strings.Contains(csp, "glue") { |
| - t.Fatalf("CSP names a glue with none configured: %s", csp) |
| + if strings.Contains(csp, "fetch.example") { |
| + t.Fatalf("CSP names a fetch service with none configured: %s", csp) |
| } |
| - if !strings.Contains(body, `window.__glueURL = ""`) { |
| - t.Fatal("page should inject an empty glue URL when unconfigured") |
| + if !strings.Contains(body, `window.__fetchURL = ""`) { |
| + t.Fatal("page should inject an empty fetch URL when unconfigured") |
| } |
| - srv.glueURL = "https://glue.example" |
| + srv.fetchURL = "https://fetch.example" |
| csp, body = get() |
| if !strings.Contains(csp, "connect-src 'self' https://*.giphy.com") || |
| - !strings.Contains(csp, " https://glue.example; ") { |
| - t.Fatalf("CSP missing the glue origin: %s", csp) |
| + !strings.Contains(csp, " https://fetch.example; ") { |
| + t.Fatalf("CSP missing the fetch service origin: %s", csp) |
| } |
| - if !strings.Contains(body, `window.__glueURL = "https://glue.example"`) { |
| - t.Fatal("page missing the injected glue URL") |
| + if !strings.Contains(body, `window.__fetchURL = "https://fetch.example"`) { |
| + t.Fatal("page missing the injected fetch URL") |
| } |
| } |
| // Service secrets survive a restart: the same DB yields the same salt and |
| // signing key, so cached rows still open and signed URLs stay valid. |
| -func TestGlueSecretsPersist(t *testing.T) { |
| +func TestFetchSecretsPersist(t *testing.T) { |
| dir := t.TempDir() |
| - g1, err := newGlueServer(dir + "/glue.db") |
| + g1, err := newFetchServer(dir + "/fetch.db") |
| if err != nil { |
| t.Fatalf("first open: %v", err) |
| } |
| salt, key := g1.salt, g1.signKey |
| g1.db.Close() |
| - g2, err := newGlueServer(dir + "/glue.db") |
| + g2, err := newFetchServer(dir + "/fetch.db") |
| if err != nil { |
| t.Fatalf("reopen: %v", err) |
| } |
| defer g2.db.Close() |
| if g2.salt != salt || string(g2.signKey) != string(key) { |
| - t.Fatal("glue secrets changed across restart") |
| + t.Fatal("fetch-service secrets changed across restart") |
| } |
| if len(key) != 32 || salt == "" { |
| t.Fatalf("weak secrets: key %d bytes, salt %q", len(key), salt) |
diff --git a/main.go b/main.go| index 72056ee..f91f924 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -83,11 +83,11 @@ type Server struct { |
| // <homeOrigin>/enroll as a user-initiated popup to back this instance up (see |
| // the enrolment cross-origin contract); set LCHAT_HOME_ORIGIN per deployment. |
| homeOrigin string |
| - // The public-web fetch glue this instance's clients should prefer for |
| - // unfurls (docs/glue.md) — an origin like https://glue.example, set via |
| - // LCHAT_GLUE_URL. Empty (the default, and the self-host default on |
| + // The public-web fetch service this instance's clients should prefer for |
| + // unfurls (docs/fetch.md) — an origin like https://fetch.example, set via |
| + // LCHAT_FETCH_URL. Empty (the default, and the self-host default on |
| // purpose) keeps unfurling on this instance's own /api/unfurl. |
| - glueURL string |
| + fetchURL string |
| // The cloud ledger's plan flag, mirrored into this instance's env by the |
| // provisioner (LCHAT_ENTITLEMENT, "free"/"paid"/"demo"). Gates the space |
| // menu's Upgrade entry (free) and the demo countdown band (demo); empty on |
| @@ -199,8 +199,8 @@ func main() { |
| case "links": |
| runLinksCmd(os.Args[2:]) |
| return |
| - case "glue": |
| - runGlueCmd(os.Args[2:]) |
| + case "fetch": |
| + runFetchCmd(os.Args[2:]) |
| return |
| case "remove": |
| runRemove(os.Args[2:]) |
| @@ -327,7 +327,7 @@ func main() { |
| } |
| s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion(), |
| appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL")), homeOrigin: homeOrigin, |
| - glueURL: strings.TrimRight(strings.TrimSpace(os.Getenv("LCHAT_GLUE_URL")), "/"), |
| + fetchURL: strings.TrimRight(strings.TrimSpace(os.Getenv("LCHAT_FETCH_URL")), "/"), |
| entitlement: strings.TrimSpace(os.Getenv("LCHAT_ENTITLEMENT")), |
| maxUpload: envBytes("LCHAT_MAX_UPLOAD_BYTES", defaultMaxUploadBytes), |
| blobQuota: envBytes("LCHAT_BLOB_QUOTA_BYTES", defaultBlobQuotaBytes), |
| @@ -756,12 +756,12 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| // image/frame sources are limited to what the app actually loads (preview |
| // images over https, YouTube's no-cookie embed); everything else is 'self'. |
| nonce := cspNonce() |
| - // The glue (docs/glue.md) is one more named connect-src host, present |
| + // The fetch service (docs/fetch.md) is one more named connect-src host, present |
| // only when this instance opts in — img-src's blanket https: already |
| // admits its proxied card images. |
| - glueSrc := "" |
| - if s.glueURL != "" { |
| - glueSrc = " " + s.glueURL |
| + fetchSrc := "" |
| + if s.fetchURL != "" { |
| + fetchSrc = " " + s.fetchURL |
| } |
| w.Header().Set("Content-Security-Policy", |
| "default-src 'self'; "+ |
| @@ -778,7 +778,7 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| // of the unfurl trade-off. The home origin admits exactly one call: the |
| // enrolment completion poll (backup.js startEnrolPoll), which sends only |
| // the client-random nonce. Keep connect-src pinned to named hosts. |
| - "connect-src 'self' https://*.giphy.com "+s.homeOrigin+glueSrc+"; "+ |
| + "connect-src 'self' https://*.giphy.com "+s.homeOrigin+fetchSrc+"; "+ |
| // 'self' admits exactly one same-origin frame: /app-sandbox, the |
| // opaque-origin cage member apps run in (memberapps.go). |
| "frame-src 'self' https://www.youtube-nocookie.com; "+ |
| @@ -803,7 +803,7 @@ func (s *Server) servePage(name string) http.HandlerFunc { |
| page = strings.ReplaceAll(page, "__APP_STORE_URL__", html.EscapeString(s.appStoreURL)) |
| // The home server the chat page enrols into for passkey backup. |
| page = strings.ReplaceAll(page, "__HOME_ORIGIN__", html.EscapeString(s.homeOrigin)) |
| - page = strings.ReplaceAll(page, "__GLUE_URL__", html.EscapeString(s.glueURL)) |
| + page = strings.ReplaceAll(page, "__FETCH_URL__", html.EscapeString(s.fetchURL)) |
| // The provisioner-pushed plan flag ("free"/"paid"; empty when self-hosted) |
| // — gates the space menu's Upgrade entry, enforced by nothing here: the |
| // checkout itself lives on the home server. |
diff --git a/subcommands.go b/subcommands.go| index c4df995..214865a 100644 |
| --- a/subcommands.go |
| +++ b/subcommands.go |
| @@ -40,7 +40,7 @@ var subcommands = []subcommand{ |
| {"cloud", "run the public self-serve space provisioner"}, |
| {"tunnel", "run the trial-tunnel gateway that fronts `lchat try`"}, |
| {"links", "run the short-link redirector (`links run`)"}, |
| - {"glue", "run the public-web fetch glue: unfurl + signed image proxy (`glue run`)"}, |
| + {"fetch", "run the public-web fetch service: unfurl + signed image proxy (`fetch run`)"}, |
| {"hooks", "run the webhook-ingest sidecar (`hooks run`)"}, |
| {"bot", "run the bot sidecar (`bot run`)"}, |
diff --git a/web/pages/index.html b/web/pages/index.html| index 3009e6b..983e565 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -31,9 +31,9 @@ |
| // demo countdown band (demo) from it — display only, nothing here enforces |
| // billing or the demo TTL. |
| window.__entitlement = "__ENTITLEMENT__"; |
| - // The public-web fetch glue this instance prefers for unfurls (docs/glue.md; |
| - // server-configured via LCHAT_GLUE_URL, empty = unfurl via this instance). |
| - window.__glueURL = "__GLUE_URL__"; |
| + // The public-web fetch service this instance prefers for unfurls (docs/fetch.md; |
| + // server-configured via LCHAT_FETCH_URL, empty = unfurl via this instance). |
| + window.__fetchURL = "__FETCH_URL__"; |
| </script> |
| <!-- Pre-paint bounce: a signed-out browser visit belongs on home's sign-in |
| page. This must be client-side — the session lives in localStorage, which |
diff --git a/web/static/shell.js b/web/static/shell.js| index c333123..a969cee 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -4924,15 +4924,15 @@ setChatAppHost({ |
| loadBlob: (threadId, att, onProgress) => loadBlob(att.id, threadId, att.type, onProgress), |
| upload: (threadId, file, onProgress) => chatAppUpload(threadId, file, onProgress), |
| unfurl: async (url) => { |
| - // Prefer the shared glue when this instance points at one (docs/glue.md): |
| + // Prefer the shared fetch service when this instance points at one (docs/fetch.md): |
| // identity-free — no token rides along — and the card's image comes back |
| - // as the glue's signed proxy URL. Any transport failure falls through to |
| - // the instance's own unfurler, so a glue outage degrades to today's path |
| - // (an ok:false glue answer is a real answer — the page had nothing — not |
| + // as the service's signed proxy URL. Any transport failure falls through to |
| + // the instance's own unfurler, so a fetch-service outage degrades to today's path |
| + // (an ok:false answer is a real answer — the page had nothing — not |
| // a reason to re-ask the instance). |
| - if (window.__glueURL) { |
| + if (window.__fetchURL) { |
| try { |
| - const r = await fetch(`${window.__glueURL}/api/unfurl?url=${encodeURIComponent(url)}`); |
| + const r = await fetch(`${window.__fetchURL}/api/unfurl?url=${encodeURIComponent(url)}`); |
| if (r.ok) return await r.json(); |
| } catch {} |
| } |