Lexicon-driven ATProto AppView in Gleam — port of HappyView
0

Configure Feed

Select the types of activity you want to include in your feed.

Add web UI, ATProto OAuth/DPoP, and optional client keys.

Port OAuth (PKCE + PAR + DPoP) from gitproxy with a web client-metadata
document for hosted HTTPS callbacks, and proxy procedure writes to the
user's PDS when local writes are disabled. Ship a first-party SPA under
priv/public, serve it from the router, and treat missing X-Client-Key as
the built-in web client so the console needs no API key.

author
nandi
date (Jul 26, 2026, 11:27 AM -0700) commit 8e03d6ba parent 269b9fb6 change-id kpuzvwvp
+3694 -107
+7
.env.example
··· 23 23 # Dev-only: allow unauthenticated local procedure writes into the index 24 24 # (HappyView always proxies to the user's PDS; GleamView MVP can index locally) 25 25 ALLOW_LOCAL_WRITES=1 26 + 27 + # OAuth session storage (default: $XDG_STATE_HOME/gleamview or ~/.local/state/gleamview) 28 + # GLEAMVIEW_STATE_DIR=/var/lib/gleamview 29 + 30 + # Dev-only: allow unauthenticated local procedure writes into the index 31 + # Set to 0 to require OAuth sessions and proxy writes to the user's PDS 32 + # ALLOW_LOCAL_WRITES=1
+41 -3
README.md
··· 14 14 | Lexicon upload / registry (runtime) | ✅ | 15 15 | XRPC queries (list + get by `uri`) | ✅ | 16 16 | XRPC procedures (local index mode) | ✅ (dev) | 17 - | API client keys (`X-Client-Key`) | ✅ | 17 + | API client keys (`X-Client-Key`, optional) | ✅ | 18 18 | Admin API (lexicons, records, clients, stats) | ✅ | 19 19 | Jetstream consumer | ✅ (basic) | 20 20 | DID/PDS resolve helpers | ✅ | 21 - | OAuth + DPoP PDS write proxy | ⏳ planned | 21 + | OAuth + DPoP PDS write proxy | ✅ | 22 22 | Lua / script hooks | ⏳ planned | 23 23 | WASM plugins, spaces, backfill jobs | ⏳ planned | 24 24 | Next.js admin dashboard | ⏳ planned | 25 + 26 + ## Web console 27 + 28 + Open `http://127.0.0.1:3000/` after `gleam run` for the built-in UI: 29 + 30 + - **Feed** — list indexed `com.example.post` records 31 + - **Compose** — create posts (local index or PDS via OAuth) 32 + - **Sign in** — ATProto OAuth (DPoP) 33 + - **Lexicons / Admin** — registry, stats, API clients (needs admin key) 34 + - **Settings** — admin key (optional client key for third-party apps) 35 + 36 + Static assets live in `priv/public/` and are served at `/static/*`. 25 37 26 38 ## Quick start 27 39 ··· 136 148 -H "X-Client-Key: $CLIENT" 137 149 ``` 138 150 139 - `ALLOW_LOCAL_WRITES=1` (default) indexes procedures into SQLite without proxying to a PDS. HappyView always writes through the user's PDS via OAuth/DPoP — that path is next on the port list. 151 + `ALLOW_LOCAL_WRITES=1` (default) indexes procedures into SQLite without proxying to a PDS. With `ALLOW_LOCAL_WRITES=0`, procedures proxy create/put/delete to the user's PDS via OAuth+DPoP (see OAuth section). 152 + 153 + 154 + ### OAuth (PDS writes) 155 + 156 + Browser login (public loopback client metadata via redirect URI) stores a DPoP-bound session, then procedures with `ALLOW_LOCAL_WRITES=0` proxy to the user's PDS: 157 + 158 + ```bash 159 + # 1. Login (opens authorization server) 160 + open "http://127.0.0.1:3000/oauth/login?handle=you.bsky.social" 161 + # callback: GET /oauth/callback → session on disk 162 + 163 + # 2. Check session 164 + curl -s 'http://127.0.0.1:3000/oauth/session?handle=you.bsky.social' 165 + 166 + # 3. Call a procedure (PDS createRecord + local index) 167 + # set ALLOW_LOCAL_WRITES=0 and use the DID from the session 168 + curl -s -X POST http://127.0.0.1:3000/xrpc/com.example.createPost \ 169 + -H "X-Client-Key: $CLIENT" \ 170 + -H "X-Gleamview-Did: did:plc:…" \ 171 + -H "Content-Type: application/json" \ 172 + -d '{"text":"from pds"}' 173 + ``` 174 + 175 + Ported from [gitproxy](../gitproxy) OAuth (PKCE + PAR + DPoP ES256). 140 176 141 177 ## Architecture 142 178 ··· 161 197 | `gleamview/auth` | API clients + admin keys | 162 198 | `gleamview/db` | SQLite actor | 163 199 | `gleamview/resolve` | PLC / did:web helpers | 200 + | `gleamview/oauth` | ATProto OAuth (PKCE + PAR + DPoP) | 201 + | `gleamview/pds_write` | Proxy create/put/delete to PDS | 164 202 165 203 ## Config 166 204
+27
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1784796856, 6 + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixos-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs" 22 + } 23 + } 24 + }, 25 + "root": "root", 26 + "version": 7 27 + }
+536
priv/public/app.js
··· 1 + /** 2 + * GleamView web console — vanilla ES module SPA. 3 + * Keys live in localStorage only; XRPC needs X-Client-Key, admin needs Bearer. 4 + */ 5 + 6 + const STORAGE = { 7 + clientKey: "gleamview.clientKey", 8 + adminKey: "gleamview.adminKey", 9 + did: "gleamview.did", 10 + }; 11 + 12 + const $ = (sel, root = document) => root.querySelector(sel); 13 + const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; 14 + 15 + const state = { 16 + config: null, 17 + tab: "feed", 18 + }; 19 + 20 + function loadSettings() { 21 + return { 22 + clientKey: localStorage.getItem(STORAGE.clientKey) || "", 23 + adminKey: localStorage.getItem(STORAGE.adminKey) || "", 24 + did: localStorage.getItem(STORAGE.did) || "", 25 + }; 26 + } 27 + 28 + function saveSettings(partial) { 29 + const cur = loadSettings(); 30 + const next = { ...cur, ...partial }; 31 + if (next.clientKey) localStorage.setItem(STORAGE.clientKey, next.clientKey); 32 + else localStorage.removeItem(STORAGE.clientKey); 33 + if (next.adminKey) localStorage.setItem(STORAGE.adminKey, next.adminKey); 34 + else localStorage.removeItem(STORAGE.adminKey); 35 + if (next.did) localStorage.setItem(STORAGE.did, next.did); 36 + else localStorage.removeItem(STORAGE.did); 37 + return next; 38 + } 39 + 40 + function toast(msg, kind = "") { 41 + const el = $("#toast"); 42 + el.hidden = false; 43 + el.textContent = msg; 44 + el.className = "toast" + (kind ? ` ${kind}` : ""); 45 + clearTimeout(toast._t); 46 + toast._t = setTimeout(() => { 47 + el.hidden = true; 48 + }, 3800); 49 + } 50 + 51 + async function api(path, { method = "GET", headers = {}, body, json } = {}) { 52 + const opts = { method, headers: { ...headers } }; 53 + if (json !== undefined) { 54 + opts.headers["Content-Type"] = "application/json"; 55 + opts.body = JSON.stringify(json); 56 + } else if (body !== undefined) { 57 + opts.body = body; 58 + } 59 + const res = await fetch(path, opts); 60 + const text = await res.text(); 61 + let data = null; 62 + try { 63 + data = text ? JSON.parse(text) : null; 64 + } catch { 65 + data = text; 66 + } 67 + if (!res.ok) { 68 + const msg = 69 + (data && (data.message || data.error)) || 70 + text || 71 + `${res.status} ${res.statusText}`; 72 + const err = new Error(msg); 73 + err.status = res.status; 74 + err.data = data; 75 + throw err; 76 + } 77 + return data; 78 + } 79 + 80 + function withClient(headers = {}) { 81 + const { clientKey } = loadSettings(); 82 + if (clientKey) headers["X-Client-Key"] = clientKey; 83 + return headers; 84 + } 85 + 86 + function withAdmin(headers = {}) { 87 + const { adminKey } = loadSettings(); 88 + if (adminKey) headers["Authorization"] = `Bearer ${adminKey}`; 89 + return headers; 90 + } 91 + 92 + function setTab(name) { 93 + state.tab = name; 94 + $$(".tab").forEach((btn) => { 95 + const on = btn.dataset.tab === name; 96 + btn.classList.toggle("active", on); 97 + btn.setAttribute("aria-selected", on ? "true" : "false"); 98 + }); 99 + $$(".panel").forEach((panel) => { 100 + const on = panel.id === `panel-${name}`; 101 + panel.hidden = !on; 102 + panel.classList.toggle("active", on); 103 + }); 104 + if (name === "feed") loadFeed().catch(() => {}); 105 + if (name === "lexicons") loadLexicons().catch(() => {}); 106 + if (name === "admin") loadAdmin().catch(() => {}); 107 + if (name === "oauth") { 108 + const h = $("#oauth-handle").value || loadSettings().did; 109 + if (h && !h.startsWith("did:")) checkOAuth(h).catch(() => {}); 110 + } 111 + if (name === "settings") fillSettingsForm(); 112 + if (name === "compose") { 113 + const s = loadSettings(); 114 + if (s.did && !$("#compose-did").value) $("#compose-did").value = s.did; 115 + } 116 + } 117 + 118 + function fillSettingsForm() { 119 + const s = loadSettings(); 120 + $("#set-client-key").value = s.clientKey; 121 + $("#set-admin-key").value = s.adminKey; 122 + $("#set-did").value = s.did; 123 + } 124 + 125 + function esc(s) { 126 + return String(s ?? "") 127 + .replaceAll("&", "&amp;") 128 + .replaceAll("<", "&lt;") 129 + .replaceAll(">", "&gt;") 130 + .replaceAll('"', "&quot;"); 131 + } 132 + 133 + function shortDid(did) { 134 + if (!did) return "unknown"; 135 + if (did.length <= 22) return did; 136 + return did.slice(0, 12) + "…" + did.slice(-6); 137 + } 138 + 139 + function fmtTime(iso) { 140 + if (!iso) return ""; 141 + try { 142 + return new Date(iso).toLocaleString(); 143 + } catch { 144 + return iso; 145 + } 146 + } 147 + 148 + // —— Status / config —— 149 + 150 + async function refreshStatus() { 151 + const pill = $("#status-pill"); 152 + try { 153 + const [health, config] = await Promise.all([ 154 + api("/health"), 155 + api("/config"), 156 + ]); 157 + state.config = config; 158 + const mode = config.allowLocalWrites ? "local writes" : "PDS proxy"; 159 + pill.textContent = `${health.service || "ok"} · ${mode}`; 160 + pill.className = "pill ok"; 161 + $("#write-mode").textContent = mode; 162 + $("#write-mode").className = 163 + "pill sm " + (config.allowLocalWrites ? "warn" : "ok"); 164 + $("#config-json").textContent = JSON.stringify(config, null, 2); 165 + } catch (e) { 166 + pill.textContent = "offline"; 167 + pill.className = "pill err"; 168 + $("#config-json").textContent = String(e.message || e); 169 + } 170 + } 171 + 172 + // —— Feed —— 173 + 174 + async function loadFeed() { 175 + const empty = $("#feed-empty"); 176 + const list = $("#feed-list"); 177 + empty.hidden = false; 178 + empty.textContent = "Loading…"; 179 + list.hidden = true; 180 + try { 181 + const data = await api("/xrpc/com.example.getPosts?limit=50", { 182 + headers: withClient(), 183 + }); 184 + const records = data.records || data || []; 185 + if (!records.length) { 186 + empty.hidden = false; 187 + empty.textContent = "No posts indexed yet. Compose one!"; 188 + list.hidden = true; 189 + return; 190 + } 191 + empty.hidden = true; 192 + list.hidden = false; 193 + list.innerHTML = records 194 + .map((r) => { 195 + const uri = r.uri || ""; 196 + const did = uri.startsWith("at://") 197 + ? uri.slice(5).split("/")[0] 198 + : r.did || ""; 199 + const text = r.text ?? r.record?.text ?? JSON.stringify(r); 200 + const created = r.createdAt || r.indexed_at || r.indexedAt || ""; 201 + return `<article class="card post"> 202 + <div class="meta"> 203 + <span title="${esc(did)}">${esc(shortDid(did))}</span> 204 + <span>${esc(fmtTime(created))}</span> 205 + </div> 206 + <div class="body">${esc(text)}</div> 207 + <div class="sub" style="margin-top:0.55rem">${esc(uri)}</div> 208 + </article>`; 209 + }) 210 + .join(""); 211 + } catch (e) { 212 + empty.hidden = false; 213 + list.hidden = true; 214 + empty.textContent = `Failed to load feed: ${e.message}`; 215 + } 216 + } 217 + 218 + // —— Compose —— 219 + 220 + async function submitCompose(ev) { 221 + ev.preventDefault(); 222 + const text = $("#compose-text").value.trim(); 223 + const did = ($("#compose-did").value || loadSettings().did).trim(); 224 + const status = $("#compose-status"); 225 + const result = $("#compose-result"); 226 + if (!text) return; 227 + if (!did) { 228 + toast("Author DID / handle is required", "err"); 229 + return; 230 + } 231 + status.textContent = "Publishing…"; 232 + result.hidden = true; 233 + try { 234 + const body = { 235 + text, 236 + createdAt: new Date().toISOString(), 237 + }; 238 + const data = await api("/xrpc/com.example.createPost", { 239 + method: "POST", 240 + headers: withClient({ "X-Gleamview-Did": did }), 241 + json: body, 242 + }); 243 + status.textContent = "Published"; 244 + result.hidden = false; 245 + result.textContent = JSON.stringify(data, null, 2); 246 + $("#compose-text").value = ""; 247 + toast("Post created", "ok"); 248 + saveSettings({ did }); 249 + } catch (e) { 250 + status.textContent = ""; 251 + result.hidden = false; 252 + result.textContent = e.message; 253 + toast(e.message, "err"); 254 + } 255 + } 256 + 257 + // —— OAuth —— 258 + 259 + async function startOAuth(ev) { 260 + ev.preventDefault(); 261 + const handle = $("#oauth-handle").value.trim().replace(/^@/, ""); 262 + if (!handle) return; 263 + // Full navigation so the authorization server redirect works cleanly 264 + window.location.href = `/oauth/login?handle=${encodeURIComponent(handle)}`; 265 + } 266 + 267 + async function checkOAuth(handle) { 268 + const h = (handle || $("#oauth-handle").value || "").trim().replace(/^@/, ""); 269 + const box = $("#oauth-session"); 270 + if (!h) { 271 + toast("Enter a handle", "err"); 272 + return; 273 + } 274 + try { 275 + const data = await api( 276 + `/oauth/session?identity=${encodeURIComponent(h)}`, 277 + ); 278 + box.hidden = false; 279 + if (data.authenticated) { 280 + box.innerHTML = ` 281 + <div class="title">Signed in</div> 282 + <div class="sub">handle: ${esc(data.handle)}</div> 283 + <div class="sub">did: ${esc(data.did)}</div> 284 + <div class="sub">pds: ${esc(data.pds)}</div> 285 + <div class="sub">expires: ${esc(fmtTime(data.expiresAt * 1000))}</div>`; 286 + saveSettings({ did: data.did }); 287 + $("#compose-did").value = data.did; 288 + toast("Session active", "ok"); 289 + } else { 290 + box.innerHTML = `<div class="title">Not signed in</div> 291 + <div class="sub">No session for ${esc(h)}</div>`; 292 + } 293 + } catch (e) { 294 + box.hidden = false; 295 + box.innerHTML = `<div class="title">Error</div><div class="sub">${esc(e.message)}</div>`; 296 + } 297 + } 298 + 299 + async function logoutOAuth() { 300 + const handle = $("#oauth-handle").value.trim().replace(/^@/, ""); 301 + if (!handle) { 302 + toast("Enter handle to sign out", "err"); 303 + return; 304 + } 305 + try { 306 + await api(`/oauth/session?handle=${encodeURIComponent(handle)}`, { 307 + method: "DELETE", 308 + }); 309 + $("#oauth-session").hidden = true; 310 + toast("Signed out", "ok"); 311 + } catch (e) { 312 + toast(e.message, "err"); 313 + } 314 + } 315 + 316 + // —— Lexicons —— 317 + 318 + async function loadLexicons() { 319 + const list = $("#lexicon-list"); 320 + if (!loadSettings().adminKey) { 321 + list.innerHTML = 322 + '<div class="empty">Add an admin key in Settings to list lexicons.</div>'; 323 + return; 324 + } 325 + list.innerHTML = '<div class="empty">Loading…</div>'; 326 + try { 327 + const data = await api("/admin/lexicons", { headers: withAdmin() }); 328 + const items = Array.isArray(data) ? data : data.lexicons || data; 329 + if (!items?.length) { 330 + list.innerHTML = '<div class="empty">No lexicons registered.</div>'; 331 + return; 332 + } 333 + list.innerHTML = items 334 + .map((lex) => { 335 + const id = lex.id || lex.nsid || "?"; 336 + const type = lex.type || lex.lexiconType || lex.kind || ""; 337 + const rev = lex.revision ?? lex.rev ?? ""; 338 + const target = lex.targetCollection || lex.target_collection || ""; 339 + return `<div class="card"> 340 + <div class="title">${esc(id)}</div> 341 + <div class="sub">type: ${esc(type)}${rev !== "" ? ` · rev ${esc(rev)}` : ""}${ 342 + target ? ` · → ${esc(target)}` : "" 343 + }</div> 344 + </div>`; 345 + }) 346 + .join(""); 347 + } catch (e) { 348 + list.innerHTML = `<div class="empty">${esc(e.message)}</div>`; 349 + } 350 + } 351 + 352 + // —— Admin —— 353 + 354 + async function loadAdmin() { 355 + if (!loadSettings().adminKey) { 356 + $("#client-list").innerHTML = 357 + '<div class="empty">Add an admin key in Settings.</div>'; 358 + $("#record-list").innerHTML = ""; 359 + return; 360 + } 361 + try { 362 + const stats = await api("/admin/stats", { headers: withAdmin() }); 363 + $("#stat-records").textContent = stats.records ?? "—"; 364 + $("#stat-lexicons").textContent = stats.lexicons ?? "—"; 365 + } catch (e) { 366 + $("#stat-records").textContent = "—"; 367 + $("#stat-lexicons").textContent = "—"; 368 + toast(e.message, "err"); 369 + } 370 + 371 + try { 372 + const clients = await api("/admin/api-clients", { headers: withAdmin() }); 373 + const items = Array.isArray(clients) ? clients : clients.clients || []; 374 + $("#client-list").innerHTML = items.length 375 + ? items 376 + .map( 377 + (c) => `<div class="card"> 378 + <div class="title">${esc(c.name || "client")}</div> 379 + <div class="sub">${esc(c.clientKey || c.client_key || c.id || "")}</div> 380 + </div>`, 381 + ) 382 + .join("") 383 + : '<div class="empty">No API clients yet.</div>'; 384 + } catch (e) { 385 + $("#client-list").innerHTML = `<div class="empty">${esc(e.message)}</div>`; 386 + } 387 + 388 + try { 389 + const recs = await api("/admin/records?collection=com.example.post&limit=20", { headers: withAdmin() }); 390 + const items = Array.isArray(recs) ? recs : recs.records || []; 391 + $("#record-list").innerHTML = items.length 392 + ? items 393 + .map((r) => { 394 + const uri = r.uri || ""; 395 + const col = r.collection || ""; 396 + const snippet = 397 + typeof r.record === "string" 398 + ? r.record.slice(0, 120) 399 + : JSON.stringify(r.record || r).slice(0, 120); 400 + return `<div class="card"> 401 + <div class="title">${esc(col || "record")}</div> 402 + <div class="sub">${esc(uri)}</div> 403 + <div class="sub" style="margin-top:0.35rem">${esc(snippet)}</div> 404 + </div>`; 405 + }) 406 + .join("") 407 + : '<div class="empty">No records.</div>'; 408 + } catch (e) { 409 + $("#record-list").innerHTML = `<div class="empty">${esc(e.message)}</div>`; 410 + } 411 + } 412 + 413 + async function createClient(ev) { 414 + ev.preventDefault(); 415 + const name = $("#client-name").value.trim() || "web"; 416 + if (!loadSettings().adminKey) { 417 + toast("Admin key required", "err"); 418 + setTab("settings"); 419 + return; 420 + } 421 + try { 422 + const data = await api("/admin/api-clients", { 423 + method: "POST", 424 + headers: withAdmin(), 425 + json: { name }, 426 + }); 427 + const key = data.clientKey || data.client_key; 428 + const secret = data.clientSecret || data.client_secret; 429 + const box = $("#client-created"); 430 + box.hidden = false; 431 + box.innerHTML = ` 432 + <strong>Client created — copy now</strong><br/> 433 + key: <code>${esc(key)}</code><br/> 434 + secret: <code>${esc(secret || "(not shown)")}</code> 435 + <div class="row" style="margin-top:0.55rem"> 436 + <button type="button" class="btn primary" id="btn-use-client">Use this client key</button> 437 + </div>`; 438 + $("#btn-use-client")?.addEventListener("click", () => { 439 + saveSettings({ clientKey: key }); 440 + fillSettingsForm(); 441 + toast("Client key saved to Settings", "ok"); 442 + }); 443 + if (key) { 444 + // offer auto-save 445 + } 446 + $("#client-name").value = ""; 447 + await loadAdmin(); 448 + } catch (e) { 449 + toast(e.message, "err"); 450 + } 451 + } 452 + 453 + // —— Settings —— 454 + 455 + function bindSettings() { 456 + $("#form-settings").addEventListener("submit", (ev) => { 457 + ev.preventDefault(); 458 + saveSettings({ 459 + clientKey: $("#set-client-key").value.trim(), 460 + adminKey: $("#set-admin-key").value.trim(), 461 + did: $("#set-did").value.trim(), 462 + }); 463 + $("#settings-status").textContent = "Saved"; 464 + toast("Settings saved", "ok"); 465 + setTimeout(() => { 466 + $("#settings-status").textContent = ""; 467 + }, 1500); 468 + }); 469 + $("#set-show-client")?.addEventListener("change", (ev) => { 470 + const input = $("#set-client-key"); 471 + if (input) input.type = ev.target.checked ? "text" : "password"; 472 + }); 473 + $("#set-show-admin")?.addEventListener("change", (ev) => { 474 + const input = $("#set-admin-key"); 475 + if (input) input.type = ev.target.checked ? "text" : "password"; 476 + }); 477 + $("#btn-clear-settings").addEventListener("click", () => { 478 + localStorage.removeItem(STORAGE.clientKey); 479 + localStorage.removeItem(STORAGE.adminKey); 480 + localStorage.removeItem(STORAGE.did); 481 + fillSettingsForm(); 482 + toast("Cleared local settings", "ok"); 483 + }); 484 + } 485 + 486 + // —— Boot —— 487 + 488 + function bindTabs() { 489 + $$(".tab").forEach((btn) => { 490 + btn.addEventListener("click", () => setTab(btn.dataset.tab)); 491 + }); 492 + } 493 + 494 + function bindActions() { 495 + $("#btn-refresh").addEventListener("click", () => { 496 + refreshStatus(); 497 + if (state.tab === "feed") loadFeed(); 498 + if (state.tab === "admin") loadAdmin(); 499 + if (state.tab === "lexicons") loadLexicons(); 500 + }); 501 + $("#btn-load-feed").addEventListener("click", () => loadFeed()); 502 + $("#form-compose").addEventListener("submit", submitCompose); 503 + $("#form-oauth").addEventListener("submit", startOAuth); 504 + $("#btn-oauth-check").addEventListener("click", () => checkOAuth()); 505 + $("#btn-oauth-logout").addEventListener("click", () => logoutOAuth()); 506 + $("#btn-load-lexicons").addEventListener("click", () => loadLexicons()); 507 + $("#btn-load-admin").addEventListener("click", () => loadAdmin()); 508 + $("#form-client").addEventListener("submit", createClient); 509 + } 510 + 511 + function maybeOAuthReturn() { 512 + // If user landed after oauth callback page... callback is HTML. If they 513 + // navigated home with ?oauth=1 we could show message. Optional: hash. 514 + const params = new URLSearchParams(location.search); 515 + if (params.get("signed_in")) { 516 + toast("Signed in — check the Sign in tab", "ok"); 517 + setTab("oauth"); 518 + } 519 + } 520 + 521 + async function main() { 522 + bindTabs(); 523 + bindActions(); 524 + bindSettings(); 525 + fillSettingsForm(); 526 + const s = loadSettings(); 527 + if (s.did) { 528 + $("#compose-did").value = s.did; 529 + if (!s.did.startsWith("did:")) $("#oauth-handle").value = s.did; 530 + } 531 + maybeOAuthReturn(); 532 + await refreshStatus(); 533 + loadFeed().catch(() => {}); 534 + } 535 + 536 + main();
+206
priv/public/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="dark light" /> 7 + <title>GleamView</title> 8 + <link rel="stylesheet" href="/static/styles.css" /> 9 + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%230c1210'/%3E%3Cpath d='M8 22 L16 8 L24 22' fill='none' stroke='%235eead4' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='20' r='2' fill='%23fbbf24'/%3E%3C/svg%3E" /> 10 + </head> 11 + <body> 12 + <div class="bg-glow" aria-hidden="true"></div> 13 + <div id="app" class="shell"> 14 + <header class="top"> 15 + <div class="brand"> 16 + <div class="logo" aria-hidden="true"></div> 17 + <div> 18 + <h1>GleamView</h1> 19 + <p class="tagline">Lexicon-driven ATProto AppView</p> 20 + </div> 21 + </div> 22 + <div class="top-meta"> 23 + <span id="status-pill" class="pill muted">connecting…</span> 24 + <button type="button" class="btn ghost" id="btn-refresh" title="Refresh">↻</button> 25 + </div> 26 + </header> 27 + 28 + <nav class="tabs" role="tablist" aria-label="Main"> 29 + <button type="button" class="tab active" data-tab="feed" role="tab" aria-selected="true">Feed</button> 30 + <button type="button" class="tab" data-tab="compose" role="tab">Compose</button> 31 + <button type="button" class="tab" data-tab="oauth" role="tab">Sign in</button> 32 + <button type="button" class="tab" data-tab="lexicons" role="tab">Lexicons</button> 33 + <button type="button" class="tab" data-tab="admin" role="tab">Admin</button> 34 + <button type="button" class="tab" data-tab="settings" role="tab">Settings</button> 35 + </nav> 36 + 37 + <main class="main"> 38 + <!-- Feed --> 39 + <section id="panel-feed" class="panel active" role="tabpanel"> 40 + <div class="panel-head"> 41 + <div> 42 + <h2>Posts</h2> 43 + <p class="muted">Indexed records from <code>com.example.getPosts</code></p> 44 + </div> 45 + <button type="button" class="btn" id="btn-load-feed">Load feed</button> 46 + </div> 47 + <div id="feed-empty" class="empty"> 48 + Loading feed… 49 + </div> 50 + <div id="feed-list" class="card-list" hidden></div> 51 + </section> 52 + 53 + <!-- Compose --> 54 + <section id="panel-compose" class="panel" role="tabpanel" hidden> 55 + <div class="panel-head"> 56 + <div> 57 + <h2>Create post</h2> 58 + <p class="muted"> 59 + Calls <code>com.example.createPost</code> 60 + (<span id="write-mode" class="pill sm">…</span> 61 + </p> 62 + </div> 63 + </div> 64 + <form id="form-compose" class="stack"> 65 + <label> 66 + <span>Text</span> 67 + <textarea id="compose-text" rows="4" maxlength="1000" placeholder="What's happening on the network?" required></textarea> 68 + </label> 69 + <label> 70 + <span>Author DID / handle <span class="muted">(X-Gleamview-Did)</span></span> 71 + <input id="compose-did" type="text" placeholder="did:plc:… or alice.bsky.social" autocomplete="username" /> 72 + </label> 73 + <div class="row"> 74 + <button type="submit" class="btn primary">Publish</button> 75 + <span id="compose-status" class="muted"></span> 76 + </div> 77 + </form> 78 + <pre id="compose-result" class="code-block" hidden></pre> 79 + </section> 80 + 81 + <!-- OAuth --> 82 + <section id="panel-oauth" class="panel" role="tabpanel" hidden> 83 + <div class="panel-head"> 84 + <div> 85 + <h2>ATProto sign-in</h2> 86 + <p class="muted">OAuth with PKCE + PAR + DPoP. Session is stored server-side for PDS writes.</p> 87 + </div> 88 + </div> 89 + <form id="form-oauth" class="stack"> 90 + <label> 91 + <span>Handle</span> 92 + <input id="oauth-handle" type="text" placeholder="you.bsky.social" required autocomplete="username" /> 93 + </label> 94 + <div class="row"> 95 + <button type="submit" class="btn primary">Continue with OAuth</button> 96 + <button type="button" class="btn ghost" id="btn-oauth-check">Check session</button> 97 + <button type="button" class="btn ghost danger" id="btn-oauth-logout">Sign out</button> 98 + </div> 99 + </form> 100 + <div id="oauth-session" class="card" hidden></div> 101 + <p class="hint muted"> 102 + After login, set <code>ALLOW_LOCAL_WRITES=0</code> on the server and use your DID when composing 103 + so procedures proxy to your PDS. 104 + </p> 105 + </section> 106 + 107 + <!-- Lexicons --> 108 + <section id="panel-lexicons" class="panel" role="tabpanel" hidden> 109 + <div class="panel-head"> 110 + <div> 111 + <h2>Lexicon registry</h2> 112 + <p class="muted">Requires admin API key (Settings)</p> 113 + </div> 114 + <button type="button" class="btn" id="btn-load-lexicons">Refresh</button> 115 + </div> 116 + <div id="lexicon-list" class="card-list"></div> 117 + </section> 118 + 119 + <!-- Admin --> 120 + <section id="panel-admin" class="panel" role="tabpanel" hidden> 121 + <div class="panel-head"> 122 + <div> 123 + <h2>Admin</h2> 124 + <p class="muted">Stats, API clients, records</p> 125 + </div> 126 + <button type="button" class="btn" id="btn-load-admin">Refresh</button> 127 + </div> 128 + 129 + <div class="stats" id="admin-stats"> 130 + <div class="stat"><span class="stat-n" id="stat-records">—</span><span class="stat-l">records</span></div> 131 + <div class="stat"><span class="stat-n" id="stat-lexicons">—</span><span class="stat-l">lexicons</span></div> 132 + </div> 133 + 134 + <h3>API clients</h3> 135 + <form id="form-client" class="inline-form"> 136 + <input id="client-name" type="text" placeholder="client name" required /> 137 + <button type="submit" class="btn primary">Create client</button> 138 + </form> 139 + <div id="client-created" class="callout" hidden></div> 140 + <div id="client-list" class="card-list"></div> 141 + 142 + <h3>Recent records</h3> 143 + <div id="record-list" class="card-list"></div> 144 + </section> 145 + 146 + <!-- Settings --> 147 + <section id="panel-settings" class="panel" role="tabpanel" hidden> 148 + <div class="panel-head"> 149 + <div> 150 + <h2>Settings</h2> 151 + <p class="muted">Optional keys for admin tools. The web UI itself needs none.</p> 152 + </div> 153 + </div> 154 + <form id="form-settings" class="stack"> 155 + <label> 156 + <span>Admin bearer token <span class="muted">(only for Lexicons / Admin tabs)</span></span> 157 + <input id="set-admin-key" type="password" autocomplete="off" placeholder="gva_…" /> 158 + </label> 159 + <label class="check"> 160 + <input id="set-show-admin" type="checkbox" /> 161 + <span>Show admin key</span> 162 + </label> 163 + <label> 164 + <span>Default author DID / handle</span> 165 + <input id="set-did" type="text" placeholder="did:plc:… or handle" autocomplete="username" /> 166 + </label> 167 + <details class="muted-card" style="margin-top:0.5rem;padding:0.85rem 1rem"> 168 + <summary style="cursor:pointer;font-weight:650">Advanced — third-party API client key</summary> 169 + <p class="muted" style="margin:0.5rem 0 0.65rem;font-weight:500"> 170 + Only if you call XRPC from another app and want a named client. The built-in web UI does not use this. 171 + </p> 172 + <label> 173 + <span>API client key <code>X-Client-Key</code></span> 174 + <input id="set-client-key" type="password" autocomplete="off" placeholder="hvc_…" /> 175 + </label> 176 + <label class="check"> 177 + <input id="set-show-client" type="checkbox" /> 178 + <span>Show client key</span> 179 + </label> 180 + </details> 181 + <div class="row"> 182 + <button type="submit" class="btn primary">Save</button> 183 + <button type="button" class="btn ghost danger" id="btn-clear-settings">Clear all</button> 184 + <span id="settings-status" class="muted"></span> 185 + </div> 186 + </form> 187 + <div class="card muted-card"> 188 + <h3>Service config</h3> 189 + <pre id="config-json" class="code-block">loading…</pre> 190 + </div> 191 + </section> 192 + </main> 193 + 194 + <footer class="foot"> 195 + <span>Port of <a href="https://tangled.org/gamesgamesgamesgames.games/happyview" target="_blank" rel="noreferrer">HappyView</a></span> 196 + <span class="sep">·</span> 197 + <a href="/health">/health</a> 198 + <span class="sep">·</span> 199 + <a href="/config">/config</a> 200 + </footer> 201 + </div> 202 + 203 + <div id="toast" class="toast" hidden role="status"></div> 204 + <script type="module" src="/static/app.js"></script> 205 + </body> 206 + </html>
+583
priv/public/styles.css
··· 1 + :root { 2 + color-scheme: dark light; 3 + --bg: #0a0f0e; 4 + --bg-elev: #121a18; 5 + --bg-card: #15201d; 6 + --border: #243330; 7 + --border-strong: #35524a; 8 + --text: #e8f0ed; 9 + --muted: #8aa399; 10 + --accent: #5eead4; 11 + --accent-dim: #2dd4bf33; 12 + --accent-text: #042f2e; 13 + --warn: #fbbf24; 14 + --danger: #f87171; 15 + --ok: #4ade80; 16 + --shadow: 0 18px 50px #00000066; 17 + --radius: 14px; 18 + --font: "Segoe UI", ui-sans-serif, system-ui, -apple-system, sans-serif; 19 + --mono: "JetBrains Mono", "SF Mono", ui-monospace, Menlo, Consolas, monospace; 20 + --max: 52rem; 21 + } 22 + 23 + @media (prefers-color-scheme: light) { 24 + :root { 25 + --bg: #f4f7f6; 26 + --bg-elev: #ffffff; 27 + --bg-card: #ffffff; 28 + --border: #d5e0dc; 29 + --border-strong: #a8c0b8; 30 + --text: #10211c; 31 + --muted: #5b726a; 32 + --accent: #0f766e; 33 + --accent-dim: #14b8a633; 34 + --accent-text: #ecfdf5; 35 + --shadow: 0 12px 40px #0f172a14; 36 + } 37 + } 38 + 39 + * { 40 + box-sizing: border-box; 41 + } 42 + 43 + html, 44 + body { 45 + margin: 0; 46 + min-height: 100%; 47 + background: var(--bg); 48 + color: var(--text); 49 + font-family: var(--font); 50 + line-height: 1.5; 51 + } 52 + 53 + body { 54 + position: relative; 55 + overflow-x: hidden; 56 + } 57 + 58 + a { 59 + color: var(--accent); 60 + text-decoration: none; 61 + } 62 + 63 + a:hover { 64 + text-decoration: underline; 65 + } 66 + 67 + code, 68 + pre, 69 + .mono { 70 + font-family: var(--mono); 71 + } 72 + 73 + code { 74 + font-size: 0.88em; 75 + background: var(--accent-dim); 76 + color: var(--accent); 77 + padding: 0.1em 0.35em; 78 + border-radius: 6px; 79 + } 80 + 81 + .bg-glow { 82 + pointer-events: none; 83 + position: fixed; 84 + inset: -20% -10% auto; 85 + height: 55vh; 86 + background: 87 + radial-gradient(ellipse 50% 60% at 20% 20%, #14b8a622, transparent 60%), 88 + radial-gradient(ellipse 40% 50% at 80% 10%, #fbbf2414, transparent 55%); 89 + z-index: 0; 90 + } 91 + 92 + .shell { 93 + position: relative; 94 + z-index: 1; 95 + max-width: var(--max); 96 + margin: 0 auto; 97 + padding: 1.5rem 1.15rem 3rem; 98 + } 99 + 100 + .top { 101 + display: flex; 102 + align-items: flex-start; 103 + justify-content: space-between; 104 + gap: 1rem; 105 + margin-bottom: 1.25rem; 106 + } 107 + 108 + .brand { 109 + display: flex; 110 + gap: 0.85rem; 111 + align-items: center; 112 + } 113 + 114 + .logo { 115 + width: 2.6rem; 116 + height: 2.6rem; 117 + border-radius: 0.85rem; 118 + background: 119 + linear-gradient(145deg, #134e4a, #0c1210 55%), 120 + linear-gradient(145deg, transparent 40%, #5eead455); 121 + border: 1px solid var(--border-strong); 122 + box-shadow: inset 0 1px 0 #ffffff18, var(--shadow); 123 + position: relative; 124 + } 125 + 126 + .logo::after { 127 + content: ""; 128 + position: absolute; 129 + inset: 28% 30% 24%; 130 + border: 2px solid var(--accent); 131 + border-radius: 2px 2px 40% 40%; 132 + border-bottom: none; 133 + transform: rotate(180deg); 134 + } 135 + 136 + .brand h1 { 137 + margin: 0; 138 + font-size: 1.45rem; 139 + letter-spacing: -0.02em; 140 + font-weight: 700; 141 + } 142 + 143 + .tagline { 144 + margin: 0.1rem 0 0; 145 + color: var(--muted); 146 + font-size: 0.9rem; 147 + } 148 + 149 + .top-meta { 150 + display: flex; 151 + align-items: center; 152 + gap: 0.5rem; 153 + } 154 + 155 + .pill { 156 + display: inline-flex; 157 + align-items: center; 158 + gap: 0.35rem; 159 + font-size: 0.78rem; 160 + font-weight: 600; 161 + padding: 0.28rem 0.65rem; 162 + border-radius: 999px; 163 + border: 1px solid var(--border); 164 + background: var(--bg-elev); 165 + color: var(--muted); 166 + white-space: nowrap; 167 + } 168 + 169 + .pill.sm { 170 + font-size: 0.72rem; 171 + padding: 0.12rem 0.45rem; 172 + vertical-align: middle; 173 + } 174 + 175 + .pill.ok { 176 + color: var(--ok); 177 + border-color: #4ade8044; 178 + background: #4ade8014; 179 + } 180 + 181 + .pill.warn { 182 + color: var(--warn); 183 + border-color: #fbbf2444; 184 + background: #fbbf2414; 185 + } 186 + 187 + .pill.err { 188 + color: var(--danger); 189 + border-color: #f8717144; 190 + background: #f8717114; 191 + } 192 + 193 + .tabs { 194 + display: flex; 195 + gap: 0.35rem; 196 + overflow-x: auto; 197 + padding: 0.25rem; 198 + margin-bottom: 1.25rem; 199 + border: 1px solid var(--border); 200 + border-radius: 999px; 201 + background: color-mix(in srgb, var(--bg-elev) 90%, transparent); 202 + backdrop-filter: blur(10px); 203 + } 204 + 205 + .tab { 206 + appearance: none; 207 + border: 0; 208 + background: transparent; 209 + color: var(--muted); 210 + font: inherit; 211 + font-size: 0.92rem; 212 + font-weight: 600; 213 + padding: 0.55rem 0.95rem; 214 + border-radius: 999px; 215 + cursor: pointer; 216 + white-space: nowrap; 217 + transition: 0.15s ease; 218 + } 219 + 220 + .tab:hover { 221 + color: var(--text); 222 + background: #ffffff08; 223 + } 224 + 225 + .tab.active { 226 + color: var(--accent-text); 227 + background: var(--accent); 228 + } 229 + 230 + .main { 231 + min-height: 22rem; 232 + } 233 + 234 + .panel { 235 + animation: fade 0.18s ease; 236 + } 237 + 238 + .panel[hidden] { 239 + display: none !important; 240 + } 241 + 242 + @keyframes fade { 243 + from { 244 + opacity: 0; 245 + transform: translateY(4px); 246 + } 247 + to { 248 + opacity: 1; 249 + transform: none; 250 + } 251 + } 252 + 253 + .panel-head { 254 + display: flex; 255 + align-items: flex-start; 256 + justify-content: space-between; 257 + gap: 1rem; 258 + margin-bottom: 1rem; 259 + } 260 + 261 + .panel-head h2 { 262 + margin: 0; 263 + font-size: 1.2rem; 264 + letter-spacing: -0.02em; 265 + } 266 + 267 + .panel-head p { 268 + margin: 0.2rem 0 0; 269 + } 270 + 271 + .muted { 272 + color: var(--muted); 273 + } 274 + 275 + h3 { 276 + margin: 1.5rem 0 0.65rem; 277 + font-size: 0.95rem; 278 + text-transform: uppercase; 279 + letter-spacing: 0.06em; 280 + color: var(--muted); 281 + font-weight: 700; 282 + } 283 + 284 + .stack { 285 + display: flex; 286 + flex-direction: column; 287 + gap: 0.9rem; 288 + } 289 + 290 + label { 291 + display: flex; 292 + flex-direction: column; 293 + gap: 0.35rem; 294 + font-size: 0.9rem; 295 + font-weight: 600; 296 + } 297 + 298 + label.check { 299 + flex-direction: row; 300 + align-items: center; 301 + gap: 0.55rem; 302 + font-weight: 500; 303 + color: var(--muted); 304 + } 305 + 306 + input[type="text"], 307 + input[type="password"], 308 + textarea { 309 + width: 100%; 310 + font: inherit; 311 + color: var(--text); 312 + background: var(--bg); 313 + border: 1px solid var(--border); 314 + border-radius: 10px; 315 + padding: 0.7rem 0.85rem; 316 + outline: none; 317 + transition: border-color 0.15s ease, box-shadow 0.15s ease; 318 + } 319 + 320 + textarea { 321 + resize: vertical; 322 + min-height: 6rem; 323 + line-height: 1.45; 324 + } 325 + 326 + input:focus, 327 + textarea:focus { 328 + border-color: var(--accent); 329 + box-shadow: 0 0 0 3px var(--accent-dim); 330 + } 331 + 332 + .row { 333 + display: flex; 334 + flex-wrap: wrap; 335 + align-items: center; 336 + gap: 0.65rem; 337 + } 338 + 339 + .btn { 340 + appearance: none; 341 + border: 1px solid var(--border-strong); 342 + background: var(--bg-elev); 343 + color: var(--text); 344 + font: inherit; 345 + font-weight: 650; 346 + font-size: 0.92rem; 347 + padding: 0.55rem 0.95rem; 348 + border-radius: 10px; 349 + cursor: pointer; 350 + transition: 0.15s ease; 351 + } 352 + 353 + .btn:hover { 354 + border-color: var(--accent); 355 + color: var(--accent); 356 + } 357 + 358 + .btn:active { 359 + transform: translateY(1px); 360 + } 361 + 362 + .btn.primary { 363 + background: var(--accent); 364 + border-color: transparent; 365 + color: var(--accent-text); 366 + } 367 + 368 + .btn.primary:hover { 369 + filter: brightness(1.05); 370 + color: var(--accent-text); 371 + } 372 + 373 + .btn.ghost { 374 + background: transparent; 375 + } 376 + 377 + .btn.danger:hover { 378 + border-color: var(--danger); 379 + color: var(--danger); 380 + } 381 + 382 + .btn:disabled { 383 + opacity: 0.5; 384 + cursor: not-allowed; 385 + } 386 + 387 + .inline-form { 388 + display: flex; 389 + flex-wrap: wrap; 390 + gap: 0.55rem; 391 + margin-bottom: 0.75rem; 392 + } 393 + 394 + .inline-form input { 395 + flex: 1 1 12rem; 396 + } 397 + 398 + .card-list { 399 + display: flex; 400 + flex-direction: column; 401 + gap: 0.7rem; 402 + } 403 + 404 + .card { 405 + background: var(--bg-card); 406 + border: 1px solid var(--border); 407 + border-radius: var(--radius); 408 + padding: 0.95rem 1.05rem; 409 + box-shadow: var(--shadow); 410 + } 411 + 412 + .card.post .meta { 413 + display: flex; 414 + flex-wrap: wrap; 415 + gap: 0.5rem 0.85rem; 416 + margin-bottom: 0.55rem; 417 + font-size: 0.82rem; 418 + color: var(--muted); 419 + font-family: var(--mono); 420 + } 421 + 422 + .card.post .body { 423 + white-space: pre-wrap; 424 + word-break: break-word; 425 + font-size: 1.02rem; 426 + } 427 + 428 + .card .title { 429 + font-weight: 700; 430 + margin-bottom: 0.25rem; 431 + } 432 + 433 + .card .sub { 434 + color: var(--muted); 435 + font-size: 0.88rem; 436 + font-family: var(--mono); 437 + word-break: break-all; 438 + } 439 + 440 + .empty { 441 + border: 1px dashed var(--border-strong); 442 + border-radius: var(--radius); 443 + padding: 2rem 1.25rem; 444 + text-align: center; 445 + color: var(--muted); 446 + background: color-mix(in srgb, var(--bg-elev) 70%, transparent); 447 + } 448 + 449 + .stats { 450 + display: grid; 451 + grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); 452 + gap: 0.75rem; 453 + margin-bottom: 0.5rem; 454 + } 455 + 456 + .stat { 457 + background: var(--bg-card); 458 + border: 1px solid var(--border); 459 + border-radius: var(--radius); 460 + padding: 1rem 1.1rem; 461 + display: flex; 462 + flex-direction: column; 463 + gap: 0.15rem; 464 + } 465 + 466 + .stat-n { 467 + font-size: 1.75rem; 468 + font-weight: 750; 469 + letter-spacing: -0.03em; 470 + color: var(--accent); 471 + font-variant-numeric: tabular-nums; 472 + } 473 + 474 + .stat-l { 475 + color: var(--muted); 476 + font-size: 0.85rem; 477 + text-transform: uppercase; 478 + letter-spacing: 0.05em; 479 + font-weight: 650; 480 + } 481 + 482 + .code-block { 483 + margin: 0; 484 + padding: 0.9rem 1rem; 485 + background: #00000055; 486 + border: 1px solid var(--border); 487 + border-radius: 12px; 488 + overflow: auto; 489 + font-size: 0.82rem; 490 + line-height: 1.45; 491 + color: var(--muted); 492 + white-space: pre-wrap; 493 + word-break: break-word; 494 + } 495 + 496 + .callout { 497 + margin: 0.5rem 0 0.85rem; 498 + padding: 0.85rem 1rem; 499 + border-radius: 12px; 500 + border: 1px solid #fbbf2455; 501 + background: #fbbf2414; 502 + color: var(--text); 503 + font-size: 0.9rem; 504 + } 505 + 506 + .callout strong { 507 + color: var(--warn); 508 + } 509 + 510 + .callout code { 511 + user-select: all; 512 + } 513 + 514 + .hint { 515 + margin-top: 1.25rem; 516 + font-size: 0.9rem; 517 + } 518 + 519 + .muted-card { 520 + margin-top: 1.5rem; 521 + } 522 + 523 + .muted-card h3 { 524 + margin-top: 0; 525 + } 526 + 527 + .foot { 528 + margin-top: 2.5rem; 529 + padding-top: 1rem; 530 + border-top: 1px solid var(--border); 531 + color: var(--muted); 532 + font-size: 0.85rem; 533 + display: flex; 534 + flex-wrap: wrap; 535 + gap: 0.35rem 0.5rem; 536 + align-items: center; 537 + } 538 + 539 + .foot .sep { 540 + opacity: 0.5; 541 + } 542 + 543 + .toast { 544 + position: fixed; 545 + left: 50%; 546 + bottom: 1.25rem; 547 + transform: translateX(-50%); 548 + z-index: 50; 549 + max-width: min(32rem, calc(100vw - 2rem)); 550 + padding: 0.75rem 1rem; 551 + border-radius: 12px; 552 + background: var(--bg-elev); 553 + border: 1px solid var(--border-strong); 554 + box-shadow: var(--shadow); 555 + font-size: 0.92rem; 556 + animation: fade 0.15s ease; 557 + } 558 + 559 + .toast.err { 560 + border-color: #f8717166; 561 + } 562 + 563 + .toast.ok { 564 + border-color: #4ade8066; 565 + } 566 + 567 + @media (max-width: 560px) { 568 + .shell { 569 + padding: 1rem 0.85rem 2.5rem; 570 + } 571 + 572 + .top { 573 + flex-direction: column; 574 + } 575 + 576 + .panel-head { 577 + flex-direction: column; 578 + } 579 + 580 + .tabs { 581 + border-radius: 16px; 582 + } 583 + }
+11 -3
src/gleamview/auth.gleam
··· 26 26 27 27 // ── XRPC client identification ────────────────────────────────────────────── 28 28 29 - /// Resolve client key from `X-Client-Key` header or `client_key` query param. 29 + /// Resolve optional client key from `X-Client-Key` / `client_key` query. 30 + /// 31 + /// First-party use (browser UI on this server) does not need a key: missing 32 + /// identification is treated as the built-in `web` client. Third-party apps 33 + /// can still send a registered key for naming / future rate limits. 30 34 pub fn require_client(db: Db, req: Request) -> Result(ApiClient, AppError) { 31 35 case client_key_from_request(req) { 32 - None -> 33 - Error(error.Unauthorized("Missing client identification")) 36 + None -> Ok(first_party_web_client()) 34 37 Some(key) -> lookup_client(db, key) 35 38 } 39 + } 40 + 41 + /// Synthetic client for same-server UI / anonymous XRPC callers. 42 + pub fn first_party_web_client() -> ApiClient { 43 + ApiClient(id: "web", name: "gleamview-web", client_key: "") 36 44 } 37 45 38 46 pub fn client_key_from_request(req: Request) -> Option(String) {
+1322
src/gleamview/oauth.gleam
··· 1 + //// AT Protocol OAuth (public loopback client) with DPoP + PKCE + PAR. 2 + 3 + import filepath 4 + import gleamview/oauth_sys as sys 5 + import gleam/bit_array 6 + import gleam/crypto 7 + import gleam/dynamic/decode 8 + import gleam/http 9 + import gleam/http/request 10 + import gleam/httpc 11 + import gleam/int 12 + import gleam/json 13 + import gleam/list 14 + import gleam/option.{type Option, None, Some} 15 + import gleam/result 16 + import gleam/string 17 + import gleam/uri 18 + import simplifile 19 + 20 + pub type OauthError { 21 + OauthError(String) 22 + } 23 + 24 + pub fn error_message(e: OauthError) -> String { 25 + case e { 26 + OauthError(m) -> m 27 + } 28 + } 29 + 30 + /// Scope for identity + generic PDS write (blobs + records). 31 + pub const default_scope: String = "atproto transition:generic" 32 + 33 + const redirect_ports: List(Int) = [ 34 + 41_201, 41_202, 41_203, 41_204, 41_205, 41_206, 41_207, 41_208, 35 + ] 36 + 37 + const callback_timeout_ms: Int = 300_000 38 + 39 + // --------------------------------------------------------------------------- 40 + // Types 41 + // --------------------------------------------------------------------------- 42 + 43 + pub type DpopKey { 44 + DpopKey(d: String, x: String, y: String) 45 + } 46 + 47 + pub type StoredSession { 48 + StoredSession( 49 + did: String, 50 + handle: String, 51 + pds: String, 52 + issuer: String, 53 + token_endpoint: String, 54 + client_id: String, 55 + access_token: String, 56 + refresh_token: String, 57 + scope: String, 58 + expires_at: Int, 59 + dpop: DpopKey, 60 + auth_dpop_nonce: String, 61 + rs_dpop_nonce: String, 62 + ) 63 + } 64 + 65 + pub type AuthServer { 66 + AuthServer( 67 + issuer: String, 68 + authorization_endpoint: String, 69 + token_endpoint: String, 70 + par_endpoint: String, 71 + ) 72 + } 73 + 74 + // --------------------------------------------------------------------------- 75 + // FFI 76 + // --------------------------------------------------------------------------- 77 + 78 + @external(erlang, "gleamview_ffi", "open_browser") 79 + fn open_browser_ffi(url: String) -> Result(Nil, String) 80 + 81 + @external(erlang, "gleamview_ffi", "oauth_can_bind") 82 + fn oauth_can_bind_ffi(port: Int) -> Bool 83 + 84 + @external(erlang, "gleamview_ffi", "oauth_wait_callback") 85 + fn oauth_wait_callback_ffi(port: Int, timeout_ms: Int) -> Result(String, String) 86 + 87 + @external(erlang, "gleamview_ffi", "dpop_generate_keypair") 88 + fn dpop_generate_keypair_ffi() -> Result(#(String, String, String), Nil) 89 + 90 + @external(erlang, "gleamview_ffi", "dpop_sign_jwt") 91 + fn dpop_sign_jwt_ffi( 92 + signing_input: String, 93 + d_b64: String, 94 + unused: String, 95 + ) -> Result(String, String) 96 + 97 + @external(erlang, "gleamview_ffi", "base64url_encode") 98 + fn base64url_encode_ffi(data: BitArray) -> String 99 + 100 + @external(erlang, "gleamview_ffi", "random_bytes") 101 + fn random_bytes_ffi(n: Int) -> BitArray 102 + 103 + @external(erlang, "gleamview_ffi", "sha256") 104 + fn sha256_ffi(data: BitArray) -> BitArray 105 + 106 + // --------------------------------------------------------------------------- 107 + // Session paths 108 + // --------------------------------------------------------------------------- 109 + 110 + pub fn session_file_for_handle(handle: String) -> String { 111 + let key = 112 + crypto.hash(crypto.Sha256, <<"oauth:", handle:utf8>>) 113 + |> bit_array.base16_encode 114 + |> string.lowercase 115 + |> string.slice(0, 24) 116 + filepath.join(sys.state_dir(), "oauth-" <> key <> ".json") 117 + } 118 + 119 + pub fn load_session(handle: String) -> Result(StoredSession, OauthError) { 120 + let path = session_file_for_handle(handle) 121 + case simplifile.read(path) { 122 + Error(_) -> Error(OauthError("no OAuth session for " <> handle)) 123 + Ok(body) -> decode_session(body) 124 + } 125 + } 126 + 127 + pub fn save_session(session: StoredSession) -> Result(Nil, OauthError) { 128 + use _ <- result.try( 129 + sys.ensure_dir(sys.state_dir()) 130 + |> result.map_error(fn(e) { OauthError(e) }), 131 + ) 132 + let path = session_file_for_handle(session.handle) 133 + let body = session_to_json(session) 134 + case simplifile.write(to: path, contents: body) { 135 + Ok(_) -> Ok(Nil) 136 + Error(e) -> Error(OauthError("write session: " <> string.inspect(e))) 137 + } 138 + } 139 + 140 + pub fn delete_session(handle: String) -> Result(Nil, OauthError) { 141 + let path = session_file_for_handle(handle) 142 + case simplifile.delete(path) { 143 + Ok(_) | Error(simplifile.Enoent) -> Ok(Nil) 144 + Error(e) -> Error(OauthError(string.inspect(e))) 145 + } 146 + } 147 + 148 + fn session_to_json(s: StoredSession) -> String { 149 + json.object([ 150 + #("did", json.string(s.did)), 151 + #("handle", json.string(s.handle)), 152 + #("pds", json.string(s.pds)), 153 + #("issuer", json.string(s.issuer)), 154 + #("token_endpoint", json.string(s.token_endpoint)), 155 + #("client_id", json.string(s.client_id)), 156 + #("access_token", json.string(s.access_token)), 157 + #("refresh_token", json.string(s.refresh_token)), 158 + #("scope", json.string(s.scope)), 159 + #("expires_at", json.int(s.expires_at)), 160 + #( 161 + "dpop", 162 + json.object([ 163 + #("d", json.string(s.dpop.d)), 164 + #("x", json.string(s.dpop.x)), 165 + #("y", json.string(s.dpop.y)), 166 + #("kty", json.string("EC")), 167 + #("crv", json.string("P-256")), 168 + ]), 169 + ), 170 + #("auth_dpop_nonce", json.string(s.auth_dpop_nonce)), 171 + #("rs_dpop_nonce", json.string(s.rs_dpop_nonce)), 172 + ]) 173 + |> json.to_string 174 + } 175 + 176 + fn decode_session(body: String) -> Result(StoredSession, OauthError) { 177 + let decoder = { 178 + use did <- decode.field("did", decode.string) 179 + use handle <- decode.field("handle", decode.string) 180 + use pds <- decode.field("pds", decode.string) 181 + use issuer <- decode.field("issuer", decode.string) 182 + use token_endpoint <- decode.field("token_endpoint", decode.string) 183 + use client_id <- decode.optional_field("client_id", "", decode.string) 184 + use access_token <- decode.field("access_token", decode.string) 185 + use refresh_token <- decode.field("refresh_token", decode.string) 186 + use scope <- decode.field("scope", decode.string) 187 + use expires_at <- decode.field("expires_at", decode.int) 188 + use d <- decode.subfield(["dpop", "d"], decode.string) 189 + use x <- decode.subfield(["dpop", "x"], decode.string) 190 + use y <- decode.subfield(["dpop", "y"], decode.string) 191 + use auth_dpop_nonce <- decode.optional_field( 192 + "auth_dpop_nonce", 193 + "", 194 + decode.string, 195 + ) 196 + use rs_dpop_nonce <- decode.optional_field( 197 + "rs_dpop_nonce", 198 + "", 199 + decode.string, 200 + ) 201 + decode.success(StoredSession( 202 + did:, 203 + handle:, 204 + pds:, 205 + issuer:, 206 + token_endpoint:, 207 + client_id:, 208 + access_token:, 209 + refresh_token:, 210 + scope:, 211 + expires_at:, 212 + dpop: DpopKey(d:, x:, y:), 213 + auth_dpop_nonce:, 214 + rs_dpop_nonce:, 215 + )) 216 + } 217 + case json.parse(body, decoder) { 218 + Ok(s) -> Ok(s) 219 + Error(_) -> Error(OauthError("corrupt OAuth session file")) 220 + } 221 + } 222 + 223 + // --------------------------------------------------------------------------- 224 + // Login flow 225 + // --------------------------------------------------------------------------- 226 + 227 + /// Interactive browser OAuth login for `handle`. Persists session to disk. 228 + /// Tries loopback ports 41201–41208 if one is busy. 229 + pub fn login( 230 + handle: String, 231 + pds: String, 232 + scope: String, 233 + ) -> Result(StoredSession, OauthError) { 234 + use port <- result.try(pick_free_port()) 235 + login_on_port(handle, pds, scope, port) 236 + } 237 + 238 + fn pick_free_port() -> Result(Int, OauthError) { 239 + case list.find(redirect_ports, oauth_can_bind_ffi) { 240 + Ok(port) -> Ok(port) 241 + Error(_) -> 242 + Error(OauthError( 243 + "failed to bind OAuth callback on ports 41201–41208; free one and retry", 244 + )) 245 + } 246 + } 247 + 248 + fn login_on_port( 249 + handle: String, 250 + pds: String, 251 + scope: String, 252 + port: Int, 253 + ) -> Result(StoredSession, OauthError) { 254 + use auth <- result.try(discover_auth_server(pds)) 255 + use dpop <- result.try(generate_dpop_key()) 256 + let redirect_uri = "http://127.0.0.1:" <> int.to_string(port) <> "/callback" 257 + let client_id = loopback_client_id(redirect_uri, scope) 258 + let state = random_token(32) 259 + let code_verifier = random_token(48) 260 + let code_challenge = pkce_challenge(code_verifier) 261 + 262 + use #(request_uri, auth_nonce) <- result.try(pushed_authorization_request( 263 + auth.par_endpoint, 264 + client_id, 265 + redirect_uri, 266 + scope, 267 + state, 268 + code_challenge, 269 + handle, 270 + dpop, 271 + "", 272 + )) 273 + 274 + let authorize_url = 275 + auth.authorization_endpoint 276 + <> "?client_id=" 277 + <> uri.percent_encode(client_id) 278 + <> "&request_uri=" 279 + <> uri.percent_encode(request_uri) 280 + 281 + sys.eprint("Opening browser for OAuth login…") 282 + sys.eprint(authorize_url) 283 + case open_browser_ffi(authorize_url) { 284 + Ok(_) -> Nil 285 + Error(_) -> sys.eprint("(could not open browser; open the URL above)") 286 + } 287 + 288 + use path_query <- result.try( 289 + oauth_wait_callback_ffi(port, callback_timeout_ms) 290 + |> result.map_error(fn(e) { OauthError("OAuth callback: " <> e) }), 291 + ) 292 + use params <- result.try(parse_callback_params(path_query)) 293 + use _ <- result.try(verify_callback(params, state, auth.issuer)) 294 + use code <- result.try(map_get(params, "code")) 295 + 296 + use tokens <- result.try(exchange_code( 297 + auth.token_endpoint, 298 + client_id, 299 + redirect_uri, 300 + code, 301 + code_verifier, 302 + dpop, 303 + auth_nonce, 304 + )) 305 + 306 + use did <- result.try(map_get(tokens, "sub")) 307 + use access_token <- result.try(map_get(tokens, "access_token")) 308 + use refresh_token <- result.try(map_get(tokens, "refresh_token")) 309 + let granted_scope = case list.key_find(tokens, "scope") { 310 + Ok(s) -> s 311 + Error(_) -> scope 312 + } 313 + let expires_in = 314 + list.key_find(tokens, "expires_in") 315 + |> result.try(fn(s) { int.parse(s) }) 316 + |> result.unwrap(1800) 317 + let expires_at = sys.unix_seconds() + expires_in - 30 318 + let auth_nonce2 = case list.key_find(tokens, "_auth_nonce") { 319 + Ok(n) -> n 320 + Error(_) -> auth_nonce 321 + } 322 + 323 + // Bind session handle to the one we logged in as (user-supplied). 324 + let session = 325 + StoredSession( 326 + did:, 327 + handle:, 328 + pds:, 329 + issuer: auth.issuer, 330 + token_endpoint: auth.token_endpoint, 331 + client_id:, 332 + access_token:, 333 + refresh_token:, 334 + scope: granted_scope, 335 + expires_at:, 336 + dpop:, 337 + auth_dpop_nonce: auth_nonce2, 338 + rs_dpop_nonce: "", 339 + ) 340 + use _ <- result.try(save_session(session)) 341 + Ok(session) 342 + } 343 + 344 + // --------------------------------------------------------------------------- 345 + // Token refresh + authorized requests 346 + // --------------------------------------------------------------------------- 347 + 348 + pub fn ensure_fresh( 349 + session: StoredSession, 350 + ) -> Result(StoredSession, OauthError) { 351 + case session.expires_at > sys.unix_seconds() + 60 { 352 + True -> Ok(session) 353 + False -> refresh(session) 354 + } 355 + } 356 + 357 + pub fn refresh(session: StoredSession) -> Result(StoredSession, OauthError) { 358 + let client_id = case session.client_id { 359 + "" -> loopback_client_id("http://127.0.0.1:41201/callback", session.scope) 360 + c -> c 361 + } 362 + let form = [ 363 + #("grant_type", "refresh_token"), 364 + #("refresh_token", session.refresh_token), 365 + #("client_id", client_id), 366 + ] 367 + use #(body, auth_nonce) <- result.try(dpop_form_post( 368 + session.token_endpoint, 369 + form, 370 + session.dpop, 371 + session.auth_dpop_nonce, 372 + None, 373 + )) 374 + use access_token <- result.try(json_string_field(body, "access_token")) 375 + let refresh_token = 376 + json_string_field(body, "refresh_token") 377 + |> result.unwrap(session.refresh_token) 378 + let expires_in = 379 + json_int_field(body, "expires_in") 380 + |> result.unwrap(1800) 381 + let updated = 382 + StoredSession( 383 + ..session, 384 + access_token:, 385 + refresh_token:, 386 + expires_at: sys.unix_seconds() + expires_in - 30, 387 + auth_dpop_nonce: auth_nonce, 388 + ) 389 + use _ <- result.try(save_session(updated)) 390 + Ok(updated) 391 + } 392 + 393 + /// Perform an authorized HTTP request with DPoP (auto-refresh + nonce retry). 394 + pub fn authorized_request( 395 + session: StoredSession, 396 + method: http.Method, 397 + url: String, 398 + body: Option(BitArray), 399 + content_type: Option(String), 400 + ) -> Result( 401 + #(StoredSession, Int, BitArray, List(#(String, String))), 402 + OauthError, 403 + ) { 404 + use session <- result.try(ensure_fresh(session)) 405 + dpop_resource_request(session, method, url, body, content_type, True) 406 + } 407 + 408 + fn dpop_resource_request( 409 + session: StoredSession, 410 + method: http.Method, 411 + url: String, 412 + body: Option(BitArray), 413 + content_type: Option(String), 414 + allow_refresh_retry: Bool, 415 + ) -> Result( 416 + #(StoredSession, Int, BitArray, List(#(String, String))), 417 + OauthError, 418 + ) { 419 + use proof <- result.try(make_dpop_proof( 420 + method, 421 + url, 422 + session.dpop, 423 + session.rs_dpop_nonce, 424 + Some(session.access_token), 425 + )) 426 + use req <- result.try( 427 + request.to(url) 428 + |> result.map_error(fn(_) { OauthError("invalid URL: " <> url) }), 429 + ) 430 + let payload = case body { 431 + None -> <<>> 432 + Some(b) -> b 433 + } 434 + let req = 435 + req 436 + |> request.set_method(method) 437 + |> request.set_body(payload) 438 + |> request.set_header("user-agent", "gleamview") 439 + |> request.set_header("authorization", "DPoP " <> session.access_token) 440 + |> request.set_header("dpop", proof) 441 + let req = case content_type { 442 + None -> req 443 + Some(ct) -> request.set_header(req, "content-type", ct) 444 + } 445 + let req = case body { 446 + Some(b) -> 447 + request.set_header( 448 + req, 449 + "content-length", 450 + int.to_string(bit_array.byte_size(b)), 451 + ) 452 + None -> req 453 + } 454 + 455 + case httpc.send_bits(req) { 456 + Error(e) -> Error(OauthError(string.inspect(e))) 457 + Ok(resp) -> { 458 + let headers = resp.headers 459 + let nonce = header_value(headers, "dpop-nonce") 460 + let session = case nonce { 461 + Some(n) if n != "" -> StoredSession(..session, rs_dpop_nonce: n) 462 + _ -> session 463 + } 464 + case resp.status { 465 + s if s >= 200 && s < 300 -> { 466 + let _ = save_session(session) 467 + Ok(#(session, s, resp.body, headers)) 468 + } 469 + 401 -> { 470 + case is_use_dpop_nonce(resp.status, headers, resp.body) { 471 + True -> 472 + dpop_resource_request( 473 + session, 474 + method, 475 + url, 476 + body, 477 + content_type, 478 + allow_refresh_retry, 479 + ) 480 + False -> 481 + case allow_refresh_retry { 482 + True -> { 483 + use session2 <- result.try(refresh(session)) 484 + dpop_resource_request( 485 + session2, 486 + method, 487 + url, 488 + body, 489 + content_type, 490 + False, 491 + ) 492 + } 493 + False -> 494 + Error(OauthError( 495 + "authorized request HTTP 401: " 496 + <> bit_array.to_string(resp.body) |> result.unwrap(""), 497 + )) 498 + } 499 + } 500 + } 501 + s -> 502 + Error(OauthError( 503 + "authorized request HTTP " 504 + <> int.to_string(s) 505 + <> ": " 506 + <> bit_array.to_string(resp.body) |> result.unwrap(""), 507 + )) 508 + } 509 + } 510 + } 511 + } 512 + 513 + // --------------------------------------------------------------------------- 514 + // Discovery, PAR, token exchange 515 + // --------------------------------------------------------------------------- 516 + 517 + pub fn discover_auth_server(pds: String) -> Result(AuthServer, OauthError) { 518 + let pds = string.trim(pds) |> drop_trailing_slash 519 + // Prefer protected-resource → authorization_servers[0]; fall back to treating 520 + // the PDS origin itself as the authorization server (entryway deployments). 521 + let issuer = case http_get(pds <> "/.well-known/oauth-protected-resource") { 522 + Ok(pr_body) -> 523 + case decode_first_auth_server(pr_body) { 524 + Ok(iss) -> drop_trailing_slash(iss) 525 + Error(_) -> pds 526 + } 527 + Error(_) -> pds 528 + } 529 + fetch_auth_server_metadata(issuer) 530 + } 531 + 532 + fn fetch_auth_server_metadata( 533 + issuer: String, 534 + ) -> Result(AuthServer, OauthError) { 535 + let issuer = drop_trailing_slash(issuer) 536 + let as_url = issuer <> "/.well-known/oauth-authorization-server" 537 + use as_body <- result.try(http_get(as_url)) 538 + use authorization_endpoint <- result.try(json_string_field( 539 + as_body, 540 + "authorization_endpoint", 541 + )) 542 + use token_endpoint <- result.try(json_string_field(as_body, "token_endpoint")) 543 + use par_endpoint <- result.try(json_string_field( 544 + as_body, 545 + "pushed_authorization_request_endpoint", 546 + )) 547 + let issuer2 = 548 + json_string_field(as_body, "issuer") 549 + |> result.unwrap(issuer) 550 + |> drop_trailing_slash 551 + Ok(AuthServer( 552 + issuer: issuer2, 553 + authorization_endpoint:, 554 + token_endpoint:, 555 + par_endpoint:, 556 + )) 557 + } 558 + 559 + fn decode_first_auth_server(body: String) -> Result(String, OauthError) { 560 + let decoder = { 561 + use servers <- decode.field( 562 + "authorization_servers", 563 + decode.list(decode.string), 564 + ) 565 + decode.success(servers) 566 + } 567 + case json.parse(body, decoder) { 568 + Ok([first, ..]) -> Ok(first) 569 + Ok([]) -> 570 + Error(OauthError("no authorization_servers in protected resource")) 571 + Error(_) -> Error(OauthError("decode protected resource metadata failed")) 572 + } 573 + } 574 + 575 + fn pushed_authorization_request( 576 + par_endpoint: String, 577 + client_id: String, 578 + redirect_uri: String, 579 + scope: String, 580 + state: String, 581 + code_challenge: String, 582 + login_hint: String, 583 + dpop: DpopKey, 584 + nonce: String, 585 + ) -> Result(#(String, String), OauthError) { 586 + let form = [ 587 + #("client_id", client_id), 588 + #("response_type", "code"), 589 + #("code_challenge", code_challenge), 590 + #("code_challenge_method", "S256"), 591 + #("state", state), 592 + #("redirect_uri", redirect_uri), 593 + #("scope", scope), 594 + #("login_hint", login_hint), 595 + ] 596 + use #(body, new_nonce) <- result.try(dpop_form_post( 597 + par_endpoint, 598 + form, 599 + dpop, 600 + nonce, 601 + None, 602 + )) 603 + use request_uri <- result.try(json_string_field(body, "request_uri")) 604 + Ok(#(request_uri, new_nonce)) 605 + } 606 + 607 + fn exchange_code( 608 + token_endpoint: String, 609 + client_id: String, 610 + redirect_uri: String, 611 + code: String, 612 + code_verifier: String, 613 + dpop: DpopKey, 614 + nonce: String, 615 + ) -> Result(List(#(String, String)), OauthError) { 616 + let form = [ 617 + #("grant_type", "authorization_code"), 618 + #("code", code), 619 + #("redirect_uri", redirect_uri), 620 + #("client_id", client_id), 621 + #("code_verifier", code_verifier), 622 + ] 623 + use #(body, new_nonce) <- result.try(dpop_form_post( 624 + token_endpoint, 625 + form, 626 + dpop, 627 + nonce, 628 + None, 629 + )) 630 + use access_token <- result.try(json_string_field(body, "access_token")) 631 + use refresh_token <- result.try(json_string_field(body, "refresh_token")) 632 + use sub <- result.try(json_string_field(body, "sub")) 633 + let scope = json_string_field(body, "scope") |> result.unwrap(default_scope) 634 + let expires_in = 635 + json_int_field(body, "expires_in") 636 + |> result.map(int.to_string) 637 + |> result.unwrap("1800") 638 + Ok([ 639 + #("access_token", access_token), 640 + #("refresh_token", refresh_token), 641 + #("sub", sub), 642 + #("scope", scope), 643 + #("expires_in", expires_in), 644 + #("_auth_nonce", new_nonce), 645 + ]) 646 + } 647 + 648 + // --------------------------------------------------------------------------- 649 + // DPoP 650 + // --------------------------------------------------------------------------- 651 + 652 + fn generate_dpop_key() -> Result(DpopKey, OauthError) { 653 + case dpop_generate_keypair_ffi() { 654 + Ok(#(d, x, y)) -> Ok(DpopKey(d:, x:, y:)) 655 + Error(_) -> Error(OauthError("failed to generate DPoP keypair")) 656 + } 657 + } 658 + 659 + fn make_dpop_proof( 660 + method: http.Method, 661 + url: String, 662 + key: DpopKey, 663 + nonce: String, 664 + access_token: Option(String), 665 + ) -> Result(String, OauthError) { 666 + let htu = htu_from_url(url) 667 + let htm = method_string(method) 668 + let jti = random_token(16) 669 + let iat = sys.unix_seconds() 670 + 671 + let header = 672 + json.object([ 673 + #("typ", json.string("dpop+jwt")), 674 + #("alg", json.string("ES256")), 675 + #( 676 + "jwk", 677 + json.object([ 678 + #("kty", json.string("EC")), 679 + #("crv", json.string("P-256")), 680 + #("x", json.string(key.x)), 681 + #("y", json.string(key.y)), 682 + ]), 683 + ), 684 + ]) 685 + 686 + let body_fields = [ 687 + #("jti", json.string(jti)), 688 + #("htm", json.string(htm)), 689 + #("htu", json.string(htu)), 690 + #("iat", json.int(iat)), 691 + ] 692 + let body_fields = case nonce { 693 + "" -> body_fields 694 + n -> list.append(body_fields, [#("nonce", json.string(n))]) 695 + } 696 + let body_fields = case access_token { 697 + None -> body_fields 698 + Some(tok) -> 699 + list.append(body_fields, [ 700 + #("ath", json.string(access_token_hash(tok))), 701 + ]) 702 + } 703 + let payload = json.object(body_fields) 704 + 705 + let header_b64 = base64url_json(header) 706 + let payload_b64 = base64url_json(payload) 707 + let signing_input = header_b64 <> "." <> payload_b64 708 + case dpop_sign_jwt_ffi(signing_input, key.d, "") { 709 + Error(e) -> Error(OauthError("DPoP sign: " <> e)) 710 + Ok(sig) -> Ok(signing_input <> "." <> sig) 711 + } 712 + } 713 + 714 + fn access_token_hash(token: String) -> String { 715 + <<token:utf8>> 716 + |> sha256_ffi 717 + |> base64url_encode_ffi 718 + } 719 + 720 + fn base64url_json(value: json.Json) -> String { 721 + json.to_string(value) 722 + |> bit_array.from_string 723 + |> base64url_encode_ffi 724 + } 725 + 726 + fn htu_from_url(url: String) -> String { 727 + // htu is scheme://host[:port]/path without query/fragment 728 + case uri.parse(url) { 729 + Error(_) -> url 730 + Ok(u) -> { 731 + let scheme = option.unwrap(u.scheme, "https") 732 + let host = option.unwrap(u.host, "") 733 + let path = case u.path { 734 + "" -> "/" 735 + p -> p 736 + } 737 + let port = case u.port { 738 + None -> "" 739 + Some(p) -> 740 + case scheme, p { 741 + "https", 443 -> "" 742 + "http", 80 -> "" 743 + _, _ -> ":" <> int.to_string(p) 744 + } 745 + } 746 + scheme <> "://" <> host <> port <> path 747 + } 748 + } 749 + } 750 + 751 + fn method_string(m: http.Method) -> String { 752 + case m { 753 + http.Get -> "GET" 754 + http.Post -> "POST" 755 + http.Put -> "PUT" 756 + http.Delete -> "DELETE" 757 + http.Patch -> "PATCH" 758 + http.Head -> "HEAD" 759 + http.Options -> "OPTIONS" 760 + http.Trace -> "TRACE" 761 + http.Connect -> "CONNECT" 762 + http.Other(s) -> string.uppercase(s) 763 + } 764 + } 765 + 766 + // --------------------------------------------------------------------------- 767 + // HTTP helpers with DPoP form posts 768 + // --------------------------------------------------------------------------- 769 + 770 + fn dpop_form_post( 771 + url: String, 772 + form: List(#(String, String)), 773 + dpop: DpopKey, 774 + nonce: String, 775 + access_token: Option(String), 776 + ) -> Result(#(String, String), OauthError) { 777 + dpop_form_post_retry(url, form, dpop, nonce, access_token, True) 778 + } 779 + 780 + fn dpop_form_post_retry( 781 + url: String, 782 + form: List(#(String, String)), 783 + dpop: DpopKey, 784 + nonce: String, 785 + access_token: Option(String), 786 + allow_retry: Bool, 787 + ) -> Result(#(String, String), OauthError) { 788 + use proof <- result.try(make_dpop_proof( 789 + http.Post, 790 + url, 791 + dpop, 792 + nonce, 793 + access_token, 794 + )) 795 + let body = form_encode(form) 796 + use req <- result.try( 797 + request.to(url) 798 + |> result.map_error(fn(_) { OauthError("invalid URL: " <> url) }), 799 + ) 800 + let req = 801 + req 802 + |> request.set_method(http.Post) 803 + |> request.set_body(bit_array.from_string(body)) 804 + |> request.set_header("user-agent", "gleamview") 805 + |> request.set_header("content-type", "application/x-www-form-urlencoded") 806 + |> request.set_header( 807 + "content-length", 808 + int.to_string(string.byte_size(body)), 809 + ) 810 + |> request.set_header("dpop", proof) 811 + 812 + case httpc.send_bits(req) { 813 + Error(e) -> Error(OauthError(string.inspect(e))) 814 + Ok(resp) -> { 815 + let new_nonce = case header_value(resp.headers, "dpop-nonce") { 816 + Some(n) if n != "" -> n 817 + _ -> nonce 818 + } 819 + case resp.status { 820 + s if s >= 200 && s < 300 -> 821 + case bit_array.to_string(resp.body) { 822 + Ok(text) -> Ok(#(text, new_nonce)) 823 + Error(_) -> Error(OauthError("response not UTF-8")) 824 + } 825 + s -> { 826 + let text = bit_array.to_string(resp.body) |> result.unwrap("") 827 + case 828 + allow_retry 829 + && is_use_dpop_nonce(s, resp.headers, resp.body) 830 + && new_nonce != "" 831 + { 832 + True -> 833 + dpop_form_post_retry( 834 + url, 835 + form, 836 + dpop, 837 + new_nonce, 838 + access_token, 839 + False, 840 + ) 841 + False -> 842 + Error(OauthError( 843 + "HTTP " <> int.to_string(s) <> " " <> url <> ": " <> text, 844 + )) 845 + } 846 + } 847 + } 848 + } 849 + } 850 + } 851 + 852 + fn is_use_dpop_nonce( 853 + status: Int, 854 + headers: List(#(String, String)), 855 + body: BitArray, 856 + ) -> Bool { 857 + let text = bit_array.to_string(body) |> result.unwrap("") 858 + let from_body = string.contains(text, "use_dpop_nonce") 859 + let from_www = 860 + header_value(headers, "www-authenticate") 861 + |> option.map(fn(v) { 862 + string.contains(string.lowercase(v), "use_dpop_nonce") 863 + }) 864 + |> option.unwrap(False) 865 + { status == 400 || status == 401 } && { from_body || from_www } 866 + } 867 + 868 + fn header_value( 869 + headers: List(#(String, String)), 870 + name: String, 871 + ) -> Option(String) { 872 + let want = string.lowercase(name) 873 + case 874 + list.find(headers, fn(h) { 875 + let #(k, _) = h 876 + string.lowercase(k) == want 877 + }) 878 + { 879 + Ok(#(_, v)) -> Some(v) 880 + Error(_) -> None 881 + } 882 + } 883 + 884 + fn http_get(url: String) -> Result(String, OauthError) { 885 + use req <- result.try( 886 + request.to(url) 887 + |> result.map_error(fn(_) { OauthError("invalid URL: " <> url) }), 888 + ) 889 + let req = 890 + req 891 + |> request.set_method(http.Get) 892 + |> request.set_body(<<>>) 893 + |> request.set_header("user-agent", "gleamview") 894 + case httpc.send_bits(req) { 895 + Error(e) -> Error(OauthError(string.inspect(e))) 896 + Ok(resp) if resp.status >= 200 && resp.status < 300 -> 897 + case bit_array.to_string(resp.body) { 898 + Ok(s) -> Ok(s) 899 + Error(_) -> Error(OauthError("response not UTF-8")) 900 + } 901 + Ok(resp) -> 902 + Error(OauthError( 903 + "HTTP " 904 + <> int.to_string(resp.status) 905 + <> ": " 906 + <> bit_array.to_string(resp.body) |> result.unwrap(""), 907 + )) 908 + } 909 + } 910 + 911 + // --------------------------------------------------------------------------- 912 + // Helpers 913 + // --------------------------------------------------------------------------- 914 + 915 + pub fn loopback_client_id(redirect_uri: String, scope: String) -> String { 916 + "http://localhost?redirect_uri=" 917 + <> uri.percent_encode(redirect_uri) 918 + <> "&scope=" 919 + <> uri.percent_encode(scope) 920 + } 921 + 922 + /// ATProto public web clients use a URL client_id that serves the client 923 + /// metadata document (not the localhost query-string form). 924 + pub fn web_client_id(public_url: String) -> String { 925 + let base = drop_trailing_slash(string.trim(public_url)) 926 + base <> "/oauth-client-metadata.json" 927 + } 928 + 929 + fn pkce_challenge(verifier: String) -> String { 930 + <<verifier:utf8>> 931 + |> sha256_ffi 932 + |> base64url_encode_ffi 933 + } 934 + 935 + fn random_token(nbytes: Int) -> String { 936 + random_bytes_ffi(nbytes) 937 + |> base64url_encode_ffi 938 + } 939 + 940 + fn form_encode(fields: List(#(String, String))) -> String { 941 + fields 942 + |> list.map(fn(f) { 943 + let #(k, v) = f 944 + uri.percent_encode(k) <> "=" <> uri.percent_encode(v) 945 + }) 946 + |> string.join("&") 947 + } 948 + 949 + fn parse_callback_params( 950 + path_query: String, 951 + ) -> Result(List(#(String, String)), OauthError) { 952 + let query = case string.split_once(path_query, "?") { 953 + Ok(#(_, q)) -> q 954 + Error(_) -> 955 + case string.starts_with(path_query, "?") { 956 + True -> string.drop_start(path_query, 1) 957 + False -> "" 958 + } 959 + } 960 + case query { 961 + "" -> Error(OauthError("OAuth callback missing query parameters")) 962 + q -> { 963 + let pairs = 964 + string.split(q, "&") 965 + |> list.filter_map(fn(pair) { 966 + case string.split_once(pair, "=") { 967 + Ok(#(k, v)) -> 968 + Ok(#( 969 + uri.percent_decode(k) |> result.unwrap(k), 970 + uri.percent_decode(v) |> result.unwrap(v), 971 + )) 972 + Error(_) -> Error(Nil) 973 + } 974 + }) 975 + Ok(pairs) 976 + } 977 + } 978 + } 979 + 980 + fn verify_callback( 981 + params: List(#(String, String)), 982 + expected_state: String, 983 + expected_issuer: String, 984 + ) -> Result(Nil, OauthError) { 985 + case list.key_find(params, "error") { 986 + Ok(err) -> { 987 + let desc = list.key_find(params, "error_description") |> result.unwrap("") 988 + Error(OauthError("OAuth error: " <> err <> " " <> desc)) 989 + } 990 + Error(_) -> { 991 + use state <- result.try(map_get(params, "state")) 992 + case state == expected_state { 993 + False -> Error(OauthError("OAuth state mismatch")) 994 + True -> { 995 + case list.key_find(params, "iss") { 996 + Error(_) -> Ok(Nil) 997 + Ok(iss) -> 998 + case 999 + drop_trailing_slash(iss) == drop_trailing_slash(expected_issuer) 1000 + { 1001 + True -> Ok(Nil) 1002 + False -> 1003 + Error(OauthError( 1004 + "OAuth issuer mismatch: " 1005 + <> iss 1006 + <> " vs " 1007 + <> expected_issuer, 1008 + )) 1009 + } 1010 + } 1011 + } 1012 + } 1013 + } 1014 + } 1015 + } 1016 + 1017 + fn map_get( 1018 + pairs: List(#(String, String)), 1019 + key: String, 1020 + ) -> Result(String, OauthError) { 1021 + case list.key_find(pairs, key) { 1022 + Ok(v) -> Ok(v) 1023 + Error(_) -> Error(OauthError("missing field " <> key)) 1024 + } 1025 + } 1026 + 1027 + fn json_string_field( 1028 + body: String, 1029 + field: String, 1030 + ) -> Result(String, OauthError) { 1031 + let decoder = { 1032 + use value <- decode.field(field, decode.string) 1033 + decode.success(value) 1034 + } 1035 + case json.parse(body, decoder) { 1036 + Ok(v) -> Ok(v) 1037 + Error(_) -> Error(OauthError("missing JSON field " <> field)) 1038 + } 1039 + } 1040 + 1041 + fn json_int_field(body: String, field: String) -> Result(Int, OauthError) { 1042 + let decoder = { 1043 + use value <- decode.field(field, decode.int) 1044 + decode.success(value) 1045 + } 1046 + case json.parse(body, decoder) { 1047 + Ok(v) -> Ok(v) 1048 + Error(_) -> Error(OauthError("missing JSON int field " <> field)) 1049 + } 1050 + } 1051 + 1052 + fn drop_trailing_slash(s: String) -> String { 1053 + case string.ends_with(s, "/") { 1054 + True -> string.drop_end(s, 1) 1055 + False -> s 1056 + } 1057 + } 1058 + 1059 + // --------------------------------------------------------------------------- 1060 + // Web OAuth (server redirect_uri at PUBLIC_URL/oauth/callback) 1061 + // --------------------------------------------------------------------------- 1062 + 1063 + pub type PendingLogin { 1064 + PendingLogin( 1065 + handle: String, 1066 + pds: String, 1067 + scope: String, 1068 + client_id: String, 1069 + redirect_uri: String, 1070 + code_verifier: String, 1071 + state: String, 1072 + dpop: DpopKey, 1073 + auth_nonce: String, 1074 + issuer: String, 1075 + token_endpoint: String, 1076 + ) 1077 + } 1078 + 1079 + fn pending_path(state: String) -> String { 1080 + let key = 1081 + crypto.hash(crypto.Sha256, <<"pending:", state:utf8>>) 1082 + |> bit_array.base16_encode 1083 + |> string.lowercase 1084 + |> string.slice(0, 24) 1085 + filepath.join(sys.state_dir(), "oauth-pending-" <> key <> ".json") 1086 + } 1087 + 1088 + /// Begin interactive OAuth for a web server. Returns the authorize URL. 1089 + /// `client_id` must be a public HTTPS URL serving the client metadata document 1090 + /// (see `web_client_id`). Callback hits `redirect_uri` with `?code=&state=`. 1091 + pub fn start_web_login( 1092 + handle: String, 1093 + pds: String, 1094 + scope: String, 1095 + redirect_uri: String, 1096 + client_id: String, 1097 + ) -> Result(String, OauthError) { 1098 + use auth <- result.try(discover_auth_server(pds)) 1099 + use dpop <- result.try(generate_dpop_key()) 1100 + let state = random_token(32) 1101 + let code_verifier = random_token(48) 1102 + let code_challenge = pkce_challenge(code_verifier) 1103 + 1104 + use #(request_uri, auth_nonce) <- result.try(pushed_authorization_request( 1105 + auth.par_endpoint, 1106 + client_id, 1107 + redirect_uri, 1108 + scope, 1109 + state, 1110 + code_challenge, 1111 + handle, 1112 + dpop, 1113 + "", 1114 + )) 1115 + 1116 + let pending = 1117 + PendingLogin( 1118 + handle:, 1119 + pds:, 1120 + scope:, 1121 + client_id:, 1122 + redirect_uri:, 1123 + code_verifier:, 1124 + state:, 1125 + dpop:, 1126 + auth_nonce:, 1127 + issuer: auth.issuer, 1128 + token_endpoint: auth.token_endpoint, 1129 + ) 1130 + use _ <- result.try(save_pending(pending)) 1131 + 1132 + let authorize_url = 1133 + auth.authorization_endpoint 1134 + <> "?client_id=" 1135 + <> uri.percent_encode(client_id) 1136 + <> "&request_uri=" 1137 + <> uri.percent_encode(request_uri) 1138 + Ok(authorize_url) 1139 + } 1140 + 1141 + /// Complete web OAuth after the authorization server redirects to our callback. 1142 + /// `path_query` is the request path+query (e.g. `/oauth/callback?code=…&state=…`). 1143 + pub fn finish_web_login(path_query: String) -> Result(StoredSession, OauthError) { 1144 + use params <- result.try(parse_callback_params(path_query)) 1145 + use state <- result.try(map_get(params, "state")) 1146 + use pending <- result.try(load_pending(state)) 1147 + use _ <- result.try(verify_callback(params, pending.state, pending.issuer)) 1148 + use code <- result.try(map_get(params, "code")) 1149 + 1150 + use tokens <- result.try(exchange_code( 1151 + pending.token_endpoint, 1152 + pending.client_id, 1153 + pending.redirect_uri, 1154 + code, 1155 + pending.code_verifier, 1156 + pending.dpop, 1157 + pending.auth_nonce, 1158 + )) 1159 + 1160 + use did <- result.try(map_get(tokens, "sub")) 1161 + use access_token <- result.try(map_get(tokens, "access_token")) 1162 + use refresh_token <- result.try(map_get(tokens, "refresh_token")) 1163 + let granted_scope = case list.key_find(tokens, "scope") { 1164 + Ok(s) -> s 1165 + Error(_) -> pending.scope 1166 + } 1167 + let expires_in = 1168 + list.key_find(tokens, "expires_in") 1169 + |> result.try(fn(s) { int.parse(s) }) 1170 + |> result.unwrap(1800) 1171 + let expires_at = sys.unix_seconds() + expires_in - 30 1172 + let auth_nonce2 = case list.key_find(tokens, "_auth_nonce") { 1173 + Ok(n) -> n 1174 + Error(_) -> pending.auth_nonce 1175 + } 1176 + 1177 + let session = 1178 + StoredSession( 1179 + did:, 1180 + handle: pending.handle, 1181 + pds: pending.pds, 1182 + issuer: pending.issuer, 1183 + token_endpoint: pending.token_endpoint, 1184 + client_id: pending.client_id, 1185 + access_token:, 1186 + refresh_token:, 1187 + scope: granted_scope, 1188 + expires_at:, 1189 + dpop: pending.dpop, 1190 + auth_dpop_nonce: auth_nonce2, 1191 + rs_dpop_nonce: "", 1192 + ) 1193 + use _ <- result.try(save_session(session)) 1194 + let _ = delete_pending(state) 1195 + Ok(session) 1196 + } 1197 + 1198 + fn save_pending(p: PendingLogin) -> Result(Nil, OauthError) { 1199 + use _ <- result.try( 1200 + sys.ensure_dir(sys.state_dir()) 1201 + |> result.map_error(fn(e) { OauthError(e) }), 1202 + ) 1203 + let body = 1204 + json.object([ 1205 + #("handle", json.string(p.handle)), 1206 + #("pds", json.string(p.pds)), 1207 + #("scope", json.string(p.scope)), 1208 + #("client_id", json.string(p.client_id)), 1209 + #("redirect_uri", json.string(p.redirect_uri)), 1210 + #("code_verifier", json.string(p.code_verifier)), 1211 + #("state", json.string(p.state)), 1212 + #( 1213 + "dpop", 1214 + json.object([ 1215 + #("d", json.string(p.dpop.d)), 1216 + #("x", json.string(p.dpop.x)), 1217 + #("y", json.string(p.dpop.y)), 1218 + ]), 1219 + ), 1220 + #("auth_nonce", json.string(p.auth_nonce)), 1221 + #("issuer", json.string(p.issuer)), 1222 + #("token_endpoint", json.string(p.token_endpoint)), 1223 + ]) 1224 + |> json.to_string 1225 + case simplifile.write(to: pending_path(p.state), contents: body) { 1226 + Ok(_) -> Ok(Nil) 1227 + Error(e) -> Error(OauthError("write pending: " <> string.inspect(e))) 1228 + } 1229 + } 1230 + 1231 + fn load_pending(state: String) -> Result(PendingLogin, OauthError) { 1232 + case simplifile.read(pending_path(state)) { 1233 + Error(_) -> Error(OauthError("unknown or expired OAuth state")) 1234 + Ok(body) -> { 1235 + let decoder = { 1236 + use handle <- decode.field("handle", decode.string) 1237 + use pds <- decode.field("pds", decode.string) 1238 + use scope <- decode.field("scope", decode.string) 1239 + use client_id <- decode.field("client_id", decode.string) 1240 + use redirect_uri <- decode.field("redirect_uri", decode.string) 1241 + use code_verifier <- decode.field("code_verifier", decode.string) 1242 + use state <- decode.field("state", decode.string) 1243 + use d <- decode.subfield(["dpop", "d"], decode.string) 1244 + use x <- decode.subfield(["dpop", "x"], decode.string) 1245 + use y <- decode.subfield(["dpop", "y"], decode.string) 1246 + use auth_nonce <- decode.field("auth_nonce", decode.string) 1247 + use issuer <- decode.field("issuer", decode.string) 1248 + use token_endpoint <- decode.field("token_endpoint", decode.string) 1249 + decode.success(PendingLogin( 1250 + handle:, 1251 + pds:, 1252 + scope:, 1253 + client_id:, 1254 + redirect_uri:, 1255 + code_verifier:, 1256 + state:, 1257 + dpop: DpopKey(d:, x:, y:), 1258 + auth_nonce:, 1259 + issuer:, 1260 + token_endpoint:, 1261 + )) 1262 + } 1263 + case json.parse(body, decoder) { 1264 + Ok(p) -> Ok(p) 1265 + Error(_) -> Error(OauthError("corrupt OAuth pending file")) 1266 + } 1267 + } 1268 + } 1269 + } 1270 + 1271 + fn delete_pending(state: String) -> Nil { 1272 + let _ = simplifile.delete(pending_path(state)) 1273 + Nil 1274 + } 1275 + 1276 + /// Load any stored session whose DID matches (scans state dir). 1277 + pub fn load_session_for_did(did: String) -> Result(StoredSession, OauthError) { 1278 + let dir = sys.state_dir() 1279 + case simplifile.read_directory(dir) { 1280 + Error(_) -> Error(OauthError("no OAuth session for DID " <> did)) 1281 + Ok(names) -> { 1282 + let files = 1283 + list.filter(names, fn(n) { 1284 + string.starts_with(n, "oauth-") 1285 + && !string.starts_with(n, "oauth-pending-") 1286 + && string.ends_with(n, ".json") 1287 + }) 1288 + find_session_for_did(dir, files, did) 1289 + } 1290 + } 1291 + } 1292 + 1293 + fn find_session_for_did( 1294 + dir: String, 1295 + files: List(String), 1296 + did: String, 1297 + ) -> Result(StoredSession, OauthError) { 1298 + case files { 1299 + [] -> Error(OauthError("no OAuth session for DID " <> did)) 1300 + [name, ..rest] -> { 1301 + let path = filepath.join(dir, name) 1302 + case simplifile.read(path) { 1303 + Error(_) -> find_session_for_did(dir, rest, did) 1304 + Ok(body) -> 1305 + case decode_session(body) { 1306 + Ok(s) if s.did == did -> Ok(s) 1307 + _ -> find_session_for_did(dir, rest, did) 1308 + } 1309 + } 1310 + } 1311 + } 1312 + } 1313 + 1314 + /// Resolve session by DID first, then by handle. 1315 + pub fn load_session_for_identity( 1316 + identity: String, 1317 + ) -> Result(StoredSession, OauthError) { 1318 + case string.starts_with(identity, "did:") { 1319 + True -> load_session_for_did(identity) 1320 + False -> load_session(identity) 1321 + } 1322 + }
+171
src/gleamview/oauth_http.gleam
··· 1 + //// HTTP routes for ATProto OAuth login (web redirect flow). 2 + 3 + import gleam/http 4 + import gleam/json 5 + import gleam/list 6 + import gleam/option 7 + import gleam/string 8 + import gleamview/config.{type Config} 9 + import gleamview/error 10 + import gleamview/oauth 11 + import gleamview/resolve 12 + import wisp.{type Request, type Response} 13 + 14 + /// Dispatch `/oauth/*` routes. 15 + pub fn handle(cfg: Config, req: Request, path: List(String)) -> Response { 16 + case path, req.method { 17 + ["login"], http.Get -> start_login(cfg, req) 18 + ["callback"], http.Get -> callback(req) 19 + ["session"], http.Get -> session_status(req) 20 + ["session"], http.Delete -> session_logout(req) 21 + _, _ -> wisp.not_found() 22 + } 23 + } 24 + 25 + fn start_login(cfg: Config, req: Request) -> Response { 26 + let qs = wisp.get_query(req) 27 + case list.key_find(qs, "handle") { 28 + Error(_) | Ok("") -> 29 + error.to_response(error.BadRequest("query param handle= is required")) 30 + Ok(handle) -> { 31 + case resolve.resolve_identity(cfg.plc_url, handle) { 32 + Error(e) -> error.to_response(e) 33 + Ok(ident) -> { 34 + let redirect = callback_url(cfg) 35 + let client_id = oauth.web_client_id(cfg.public_url) 36 + case 37 + oauth.start_web_login( 38 + ident.handle, 39 + ident.pds, 40 + oauth.default_scope, 41 + redirect, 42 + client_id, 43 + ) 44 + { 45 + Error(e) -> 46 + error.to_response(error.Internal(oauth.error_message(e))) 47 + Ok(authorize_url) -> 48 + // 302 to authorization server 49 + wisp.redirect(authorize_url) 50 + } 51 + } 52 + } 53 + } 54 + } 55 + } 56 + 57 + fn callback(req: Request) -> Response { 58 + // Reconstruct path?query for oauth.finish_web_login 59 + let path = "/" <> string.join(wisp.path_segments(req), "/") 60 + let query = option.unwrap(req.query, "") 61 + let path_query = case query { 62 + "" -> path 63 + q -> path <> "?" <> q 64 + } 65 + case oauth.finish_web_login(path_query) { 66 + Error(e) -> 67 + html_page( 68 + 400, 69 + "OAuth failed", 70 + oauth.error_message(e), 71 + ) 72 + Ok(session) -> 73 + html_page( 74 + 200, 75 + "Signed in", 76 + "Logged in as <code>" 77 + <> session.handle 78 + <> "</code> (<code>" 79 + <> session.did 80 + <> "</code>)." 81 + <> " <a href=\"/?signed_in=1\">Back to GleamView</a> — use " 82 + <> "<code>X-Gleamview-Did: " 83 + <> session.did 84 + <> "</code> for PDS writes when local writes are disabled.", 85 + ) 86 + } 87 + } 88 + 89 + fn session_status(req: Request) -> Response { 90 + let qs = wisp.get_query(req) 91 + case list.key_find(qs, "identity") { 92 + Error(_) -> 93 + case list.key_find(qs, "handle") { 94 + Error(_) | Ok("") -> 95 + error.to_response(error.BadRequest( 96 + "query param identity= or handle= is required", 97 + )) 98 + Ok(h) -> status_for(h) 99 + } 100 + Ok("") -> 101 + error.to_response(error.BadRequest("identity must be non-empty")) 102 + Ok(id) -> status_for(id) 103 + } 104 + } 105 + 106 + fn status_for(identity: String) -> Response { 107 + case oauth.load_session_for_identity(identity) { 108 + Error(_) -> 109 + wisp.json_response( 110 + json.object([ 111 + #("authenticated", json.bool(False)), 112 + #("identity", json.string(identity)), 113 + ]) 114 + |> json.to_string, 115 + 200, 116 + ) 117 + Ok(s) -> 118 + wisp.json_response( 119 + json.object([ 120 + #("authenticated", json.bool(True)), 121 + #("identity", json.string(identity)), 122 + #("did", json.string(s.did)), 123 + #("handle", json.string(s.handle)), 124 + #("pds", json.string(s.pds)), 125 + #("scope", json.string(s.scope)), 126 + #("expiresAt", json.int(s.expires_at)), 127 + ]) 128 + |> json.to_string, 129 + 200, 130 + ) 131 + } 132 + } 133 + 134 + fn session_logout(req: Request) -> Response { 135 + let qs = wisp.get_query(req) 136 + case list.key_find(qs, "handle") { 137 + Error(_) | Ok("") -> 138 + error.to_response(error.BadRequest("query param handle= is required")) 139 + Ok(handle) -> 140 + case oauth.delete_session(handle) { 141 + Error(e) -> error.to_response(error.Internal(oauth.error_message(e))) 142 + Ok(_) -> 143 + wisp.json_response("{\"ok\":true}", 200) 144 + } 145 + } 146 + } 147 + 148 + fn callback_url(cfg: Config) -> String { 149 + let base = string.trim_end(cfg.public_url) 150 + let base = case string.ends_with(base, "/") { 151 + True -> string.drop_end(base, 1) 152 + False -> base 153 + } 154 + base <> "/oauth/callback" 155 + } 156 + 157 + fn html_page(status: Int, title: String, body_html: String) -> Response { 158 + let html = 159 + "<!doctype html><html><head><meta charset=\"utf-8\"/>" 160 + <> "<title>" 161 + <> title 162 + <> "</title>" 163 + <> "<style>body{font-family:system-ui,sans-serif;max-width:36rem;margin:3rem auto;padding:0 1rem;line-height:1.5}" 164 + <> "code{background:#8882;padding:0.1em 0.35em;border-radius:4px}</style></head><body>" 165 + <> "<h1>" 166 + <> title 167 + <> "</h1><p>" 168 + <> body_html 169 + <> "</p></body></html>" 170 + wisp.html_response(html, status) 171 + }
+55
src/gleamview/oauth_sys.gleam
··· 1 + //// Small process helpers used by OAuth (state dir, clock, dirs). 2 + 3 + import envoy 4 + import filepath 5 + import gleam/string 6 + import gleam/time/timestamp 7 + import logging 8 + import simplifile 9 + 10 + /// Unix epoch seconds (UTC). 11 + pub fn unix_seconds() -> Int { 12 + let #(secs, _) = 13 + timestamp.system_time() 14 + |> timestamp.to_unix_seconds_and_nanoseconds 15 + secs 16 + } 17 + 18 + pub fn env(name: String) -> Result(String, Nil) { 19 + case envoy.get(name) { 20 + Ok(v) if v != "" -> Ok(v) 21 + _ -> Error(Nil) 22 + } 23 + } 24 + 25 + pub fn ensure_dir(path: String) -> Result(Nil, String) { 26 + case simplifile.create_directory_all(path) { 27 + Ok(_) -> Ok(Nil) 28 + Error(e) -> Error(string.inspect(e)) 29 + } 30 + } 31 + 32 + /// State dir for OAuth session files: `$XDG_STATE_HOME/gleamview` or 33 + /// `~/.local/state/gleamview`, overridable with `GLEAMVIEW_STATE_DIR`. 34 + pub fn state_dir() -> String { 35 + case env("GLEAMVIEW_STATE_DIR") { 36 + Ok(p) -> p 37 + Error(_) -> 38 + case env("XDG_STATE_HOME") { 39 + Ok(xdg) -> filepath.join(xdg, "gleamview") 40 + Error(_) -> 41 + case env("HOME") { 42 + Ok(home) -> 43 + filepath.join( 44 + filepath.join(filepath.join(home, ".local"), "state"), 45 + "gleamview", 46 + ) 47 + Error(_) -> ".gleamview" 48 + } 49 + } 50 + } 51 + } 52 + 53 + pub fn eprint(msg: String) -> Nil { 54 + logging.log(logging.Info, msg) 55 + }
+148
src/gleamview/pds_write.gleam
··· 1 + //// Proxy repo writes to the user's PDS using a stored OAuth session (DPoP). 2 + 3 + import gleam/bit_array 4 + import gleam/dynamic/decode 5 + import gleam/http 6 + import gleam/json 7 + import gleam/option.{Some} 8 + import gleam/result 9 + import gleam/uri 10 + import gleamview/error.{type AppError} 11 + import gleamview/oauth 12 + import gleamview/util 13 + 14 + pub type WriteResult { 15 + WriteResult(uri: String, cid: String) 16 + } 17 + 18 + fn oauth_err(e: oauth.OauthError) -> AppError { 19 + error.Unauthorized(oauth.error_message(e)) 20 + } 21 + 22 + fn require_session(identity: String) -> Result(oauth.StoredSession, AppError) { 23 + oauth.load_session_for_identity(identity) 24 + |> result.map_error(oauth_err) 25 + } 26 + 27 + /// `com.atproto.repo.createRecord` 28 + pub fn create_record( 29 + identity: String, 30 + collection: String, 31 + record_json: String, 32 + rkey: String, 33 + ) -> Result(WriteResult, AppError) { 34 + use session <- result.try(require_session(identity)) 35 + // record_json is a raw JSON object string — embed as preprocessed value 36 + let payload = 37 + "{" 38 + <> "\"repo\":" 39 + <> json.to_string(json.string(session.did)) 40 + <> ",\"collection\":" 41 + <> json.to_string(json.string(collection)) 42 + <> ",\"rkey\":" 43 + <> json.to_string(json.string(rkey)) 44 + <> ",\"validate\":false" 45 + <> ",\"record\":" 46 + <> record_json 47 + <> "}" 48 + let url = session.pds <> "/xrpc/com.atproto.repo.createRecord" 49 + use #(session2, _status, body, _headers) <- result.try( 50 + oauth.authorized_request( 51 + session, 52 + http.Post, 53 + url, 54 + Some(bit_array.from_string(payload)), 55 + Some("application/json"), 56 + ) 57 + |> result.map_error(oauth_err), 58 + ) 59 + let _ = oauth.save_session(session2) 60 + decode_write_result(body) 61 + } 62 + 63 + /// `com.atproto.repo.putRecord` 64 + pub fn put_record( 65 + identity: String, 66 + collection: String, 67 + rkey: String, 68 + record_json: String, 69 + ) -> Result(WriteResult, AppError) { 70 + use session <- result.try(require_session(identity)) 71 + let payload = 72 + "{" 73 + <> "\"repo\":" 74 + <> json.to_string(json.string(session.did)) 75 + <> ",\"collection\":" 76 + <> json.to_string(json.string(collection)) 77 + <> ",\"rkey\":" 78 + <> json.to_string(json.string(rkey)) 79 + <> ",\"validate\":false" 80 + <> ",\"record\":" 81 + <> record_json 82 + <> "}" 83 + let url = session.pds <> "/xrpc/com.atproto.repo.putRecord" 84 + use #(session2, _status, body, _headers) <- result.try( 85 + oauth.authorized_request( 86 + session, 87 + http.Post, 88 + url, 89 + Some(bit_array.from_string(payload)), 90 + Some("application/json"), 91 + ) 92 + |> result.map_error(oauth_err), 93 + ) 94 + let _ = oauth.save_session(session2) 95 + decode_write_result(body) 96 + } 97 + 98 + /// `com.atproto.repo.deleteRecord` 99 + pub fn delete_record( 100 + identity: String, 101 + collection: String, 102 + rkey: String, 103 + ) -> Result(Nil, AppError) { 104 + use session <- result.try(require_session(identity)) 105 + let payload = 106 + json.object([ 107 + #("repo", json.string(session.did)), 108 + #("collection", json.string(collection)), 109 + #("rkey", json.string(rkey)), 110 + ]) 111 + |> json.to_string 112 + let url = session.pds <> "/xrpc/com.atproto.repo.deleteRecord" 113 + use #(session2, _status, _body, _headers) <- result.try( 114 + oauth.authorized_request( 115 + session, 116 + http.Post, 117 + url, 118 + Some(bit_array.from_string(payload)), 119 + Some("application/json"), 120 + ) 121 + |> result.map_error(oauth_err), 122 + ) 123 + let _ = oauth.save_session(session2) 124 + let _ = util.at_uri(session.did, collection, rkey) 125 + Ok(Nil) 126 + } 127 + 128 + fn decode_write_result(body: BitArray) -> Result(WriteResult, AppError) { 129 + use text <- result.try( 130 + bit_array.to_string(body) 131 + |> result.map_error(fn(_) { error.Internal("PDS response not UTF-8") }), 132 + ) 133 + let decoder = { 134 + use uri <- decode.field("uri", decode.string) 135 + use cid <- decode.optional_field("cid", "", decode.string) 136 + decode.success(WriteResult(uri:, cid:)) 137 + } 138 + case json.parse(text, decoder) { 139 + Ok(w) -> Ok(w) 140 + Error(_) -> 141 + Error(error.Internal("PDS write response missing uri: " <> text)) 142 + } 143 + } 144 + 145 + /// Percent-encode helper kept for future getRecord proxy paths. 146 + pub fn encode(s: String) -> String { 147 + uri.percent_encode(s) 148 + }
+61
src/gleamview/resolve.gleam
··· 12 12 import gleamview/error.{type AppError} 13 13 import gleamview/json_util 14 14 15 + 16 + pub type Identity { 17 + Identity(did: String, handle: String, pds: String) 18 + } 19 + 20 + /// Resolve a handle or DID to identity + PDS endpoint. 21 + pub fn resolve_identity( 22 + plc_url: String, 23 + identifier: String, 24 + ) -> Result(Identity, AppError) { 25 + let id = string.trim(identifier) 26 + let id = case string.starts_with(id, "@") { 27 + True -> string.drop_start(id, 1) 28 + False -> id 29 + } 30 + case string.starts_with(id, "did:") { 31 + True -> { 32 + use pds <- result.try(resolve_pds(plc_url, id)) 33 + Ok(Identity(did: id, handle: id, pds:)) 34 + } 35 + False -> { 36 + use did <- result.try(resolve_handle(id)) 37 + use pds <- result.try(resolve_pds(plc_url, did)) 38 + Ok(Identity(did:, handle: id, pds:)) 39 + } 40 + } 41 + } 42 + 43 + /// Resolve a Bluesky/ATProto handle to a DID. 44 + pub fn resolve_handle(handle: String) -> Result(String, AppError) { 45 + let handle = string.lowercase(string.trim(handle)) 46 + case resolve_handle_wellknown(handle) { 47 + Ok(did) -> Ok(did) 48 + Error(_) -> resolve_handle_api(handle) 49 + } 50 + } 51 + 52 + fn resolve_handle_wellknown(handle: String) -> Result(String, AppError) { 53 + let url = "https://" <> handle <> "/.well-known/atproto-did" 54 + use body <- result.try(http_get_text(url)) 55 + let did = string.trim(body) 56 + case string.starts_with(did, "did:") { 57 + True -> Ok(did) 58 + False -> Error(error.NotFound("invalid well-known DID for " <> handle)) 59 + } 60 + } 61 + 62 + fn resolve_handle_api(handle: String) -> Result(String, AppError) { 63 + let url = 64 + "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=" 65 + <> uri.percent_encode(handle) 66 + use body <- result.try(http_get_text(url)) 67 + use dyn <- result.try( 68 + json_util.parse(body) |> result.map_error(error.Internal), 69 + ) 70 + case json_util.field_string(dyn, "did") { 71 + Ok(did) -> Ok(did) 72 + Error(_) -> Error(error.NotFound("could not resolve handle " <> handle)) 73 + } 74 + } 75 + 15 76 /// Fetch a DID document from the PLC directory (or did:web HTTPS). 16 77 pub fn resolve_did(plc_url: String, did: String) -> Result(String, AppError) { 17 78 case string.starts_with(did, "did:plc:") {
+80 -46
src/gleamview/router.gleam
··· 1 - //// HTTP router: health, admin, xrpc, well-known. 1 + //// HTTP router: SPA UI, health, admin, xrpc, oauth, well-known. 2 2 3 + import filepath 3 4 import gleam/json 4 5 import gleam/list 5 6 import gleam/string ··· 7 8 import gleamview/config.{type Config} 8 9 import gleamview/db.{type Db} 9 10 import gleamview/lexicon.{type Registry} 11 + import gleamview/oauth_http 10 12 import gleamview/xrpc 13 + import simplifile 11 14 import wisp.{type Request, type Response} 12 15 13 16 pub type Context { ··· 18 21 use <- wisp.log_request(req) 19 22 use <- wisp.rescue_crashes 20 23 use req <- wisp.handle_head(req) 24 + use <- wisp.serve_static(req, under: "/static", from: public_dir()) 21 25 22 26 case wisp.path_segments(req) { 23 - [] -> 24 - wisp.html_response(home_html(), 200) 27 + [] -> serve_index() 28 + 29 + // SPA-friendly aliases 30 + ["app"] | ["ui"] -> serve_index() 25 31 26 32 ["health"] -> 27 33 wisp.json_response( ··· 31 37 32 38 ["config"] -> config_endpoint(ctx.config) 33 39 34 - ["oauth-client-metadata.json"] -> 35 - // Placeholder for future OAuth 36 - wisp.json_response( 37 - json.object([ 38 - #("client_id", json.string(ctx.config.public_url <> "/oauth-client-metadata.json")), 39 - #("client_name", json.string("GleamView")), 40 - #("application_type", json.string("web")), 41 - ]) 42 - |> json.to_string, 43 - 200, 44 - ) 40 + ["oauth-client-metadata.json"] -> client_metadata(ctx.config) 45 41 46 42 [".well-known", "did.json"] -> well_known_did(ctx.config) 43 + 44 + ["oauth", ..rest] -> oauth_http.handle(ctx.config, req, rest) 47 45 48 46 ["admin", ..rest] -> admin.handle(admin.Ctx(ctx.db, ctx.reg), req, rest) 49 47 ··· 58 56 } 59 57 } 60 58 59 + fn serve_index() -> Response { 60 + let path = filepath.join(public_dir(), "index.html") 61 + case simplifile.read(path) { 62 + Ok(html) -> wisp.html_response(html, 200) 63 + Error(_) -> 64 + wisp.html_response( 65 + "<!doctype html><title>GleamView</title><p>UI assets missing — expected priv/public/index.html</p>", 66 + 500, 67 + ) 68 + } 69 + } 70 + 71 + /// Resolve `priv/public` relative to common working directories. 72 + pub fn public_dir() -> String { 73 + let candidates = [ 74 + "priv/public", 75 + "gleamview/priv/public", 76 + filepath.join(".", "priv/public"), 77 + ] 78 + case first_existing_dir(candidates) { 79 + Ok(p) -> p 80 + Error(_) -> "priv/public" 81 + } 82 + } 83 + 84 + fn first_existing_dir(paths: List(String)) -> Result(String, Nil) { 85 + case paths { 86 + [] -> Error(Nil) 87 + [p, ..rest] -> 88 + case simplifile.is_directory(p) { 89 + Ok(True) -> Ok(p) 90 + _ -> first_existing_dir(rest) 91 + } 92 + } 93 + } 94 + 95 + 96 + fn client_metadata(config: Config) -> Response { 97 + let base = drop_trailing_slash(string.trim(config.public_url)) 98 + let client_id = base <> "/oauth-client-metadata.json" 99 + let redirect = base <> "/oauth/callback" 100 + let body = 101 + json.object([ 102 + #("client_id", json.string(client_id)), 103 + #("client_name", json.string("GleamView")), 104 + #("client_uri", json.string(base)), 105 + #("redirect_uris", json.preprocessed_array([json.string(redirect)])), 106 + #("scope", json.string("atproto transition:generic")), 107 + #("grant_types", json.preprocessed_array([ 108 + json.string("authorization_code"), 109 + json.string("refresh_token"), 110 + ])), 111 + #("response_types", json.preprocessed_array([json.string("code")])), 112 + #("token_endpoint_auth_method", json.string("none")), 113 + #("application_type", json.string("web")), 114 + #("dpop_bound_access_tokens", json.bool(True)), 115 + ]) 116 + |> json.to_string 117 + wisp.json_response(body, 200) 118 + } 119 + 120 + fn drop_trailing_slash(s: String) -> String { 121 + case string.ends_with(s, "/") { 122 + True -> string.drop_end(s, 1) 123 + False -> s 124 + } 125 + } 126 + 61 127 fn config_endpoint(config: Config) -> Response { 62 128 let body = 63 129 json.object([ ··· 71 137 } 72 138 73 139 fn well_known_did(config: Config) -> Response { 74 - // Minimal did:web document for the AppView service identity 75 140 let host = 76 141 config.public_url 77 142 |> string.replace("https://", "") ··· 93 158 |> json.to_string 94 159 wisp.json_response(body, 200) 95 160 } 96 - 97 - fn home_html() -> String { 98 - "<!doctype html> 99 - <html lang=\"en\"> 100 - <head> 101 - <meta charset=\"utf-8\"/> 102 - <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/> 103 - <title>GleamView</title> 104 - <style> 105 - :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; } 106 - body { max-width: 42rem; margin: 3rem auto; padding: 0 1.25rem; line-height: 1.5; } 107 - code { background: #8882; padding: 0.1em 0.35em; border-radius: 4px; } 108 - a { color: inherit; } 109 - h1 { font-size: 1.75rem; margin-bottom: 0.25rem; } 110 - .muted { opacity: 0.75; } 111 - ul { padding-left: 1.2rem; } 112 - </style> 113 - </head> 114 - <body> 115 - <h1>GleamView</h1> 116 - <p class=\"muted\">Lexicon-driven ATProto AppView — Gleam port of 117 - <a href=\"https://tangled.org/gamesgamesgamesgames.games/happyview\">HappyView</a>.</p> 118 - <ul> 119 - <li><code>GET /health</code></li> 120 - <li><code>GET /config</code></li> 121 - <li><code>/admin/*</code> — Bearer admin API key</li> 122 - <li><code>/xrpc/{nsid}</code> — <code>X-Client-Key</code> required</li> 123 - </ul> 124 - </body> 125 - </html>" 126 - }
+1 -1
src/gleamview/xrpc.gleam
··· 113 113 )) 114 114 False -> 115 115 Error(error.Unauthorized( 116 - "authentication required (OAuth/DPoP not yet implemented)", 116 + "X-Gleamview-Did (or did=) required; complete /oauth/login?handle=… first for PDS writes", 117 117 )) 118 118 } 119 119 }
+183 -54
src/gleamview/xrpc/procedure.gleam
··· 1 - //// Default XRPC procedure handling. 1 + //// Default XRPC procedure handling (local index or OAuth PDS proxy). 2 2 3 3 import gleam/dynamic.{type Dynamic} 4 - import gleam/dynamic/decode 5 4 import gleam/json 6 5 import gleam/option.{None, Some} 7 6 import gleam/result ··· 10 9 import gleamview/error.{type AppError} 11 10 import gleamview/json_util 12 11 import gleamview/lexicon.{type ParsedLexicon, Create, Delete, Update, Upsert} 12 + import gleamview/pds_write 13 13 import gleamview/records 14 14 import gleamview/util 15 15 16 - /// Claims for a write — MVP uses local DID header when ALLOW_LOCAL_WRITES is set. 16 + /// Claims for a write. `identity` is DID or handle used for OAuth session lookup. 17 17 pub type WriteClaims { 18 18 WriteClaims(did: String) 19 19 } ··· 27 27 lexicon: ParsedLexicon, 28 28 allow_local: Bool, 29 29 ) -> Result(String, AppError) { 30 - case allow_local { 31 - False -> 30 + use collection <- result.try(case lexicon.target_collection { 31 + Some(c) -> Ok(c) 32 + None -> 32 33 Error(error.BadRequest( 33 - "PDS write proxy / OAuth not yet implemented; set ALLOW_LOCAL_WRITES=1 for local index mode", 34 + method <> " has no target_collection configured", 34 35 )) 35 - True -> { 36 - use collection <- result.try(case lexicon.target_collection { 37 - Some(c) -> Ok(c) 38 - None -> 39 - Error(error.BadRequest( 40 - method <> " has no target_collection configured", 41 - )) 42 - }) 43 - let action = resolve_action(lexicon.action, input) 36 + }) 37 + let action = resolve_action(lexicon.action, input) 38 + case allow_local { 39 + True -> 44 40 case action { 45 - Create -> create_record(db, claims.did, collection, input_json) 46 - Update -> put_record(db, claims.did, collection, input) 47 - Delete -> delete_record(db, claims.did, collection, input) 48 - Upsert -> 49 - // Should not reach after resolve_action 50 - create_record(db, claims.did, collection, input_json) 41 + Create -> create_record_local(db, claims.did, collection, input_json) 42 + Update -> put_record_local(db, claims.did, collection, input, input_json) 43 + Delete -> delete_record_local(db, claims.did, collection, input) 44 + Upsert -> create_record_local(db, claims.did, collection, input_json) 51 45 } 52 - } 46 + False -> 47 + case action { 48 + Create -> create_record_pds(db, claims.did, collection, input_json) 49 + Update -> put_record_pds(db, claims.did, collection, input, input_json) 50 + Delete -> delete_record_pds(db, claims.did, collection, input) 51 + Upsert -> create_record_pds(db, claims.did, collection, input_json) 52 + } 53 53 } 54 54 } 55 55 ··· 67 67 } 68 68 } 69 69 70 - fn create_record( 70 + // --------------------------------------------------------------------------- 71 + // Local index mode 72 + // --------------------------------------------------------------------------- 73 + 74 + fn create_record_local( 71 75 db: Db, 72 76 did: String, 73 77 collection: String, 74 78 input_json: String, 75 79 ) -> Result(String, AppError) { 76 80 let rkey = util.new_token("", 13) |> tidish 77 - // Inject $type into record body if missing (best-effort string patch) 78 81 let body = ensure_type(input_json, collection) 79 82 use uri <- result.try(records.upsert(db, did, collection, rkey, body, "")) 80 83 Ok( 81 84 json.object([ 82 85 #("uri", json.string(uri)), 83 86 #("cid", json.string("")), 84 - #("commit", json.object([#("cid", json.string("")), #("rev", json.string(""))])), 87 + #("commit", json.object([ 88 + #("cid", json.string("")), 89 + #("rev", json.string("")), 90 + ])), 85 91 ]) 86 92 |> json.to_string, 87 93 ) 88 94 } 89 95 90 - fn put_record( 96 + fn put_record_local( 91 97 db: Db, 92 98 did: String, 93 99 collection: String, 94 100 input: Dynamic, 101 + input_json: String, 95 102 ) -> Result(String, AppError) { 96 103 use client_uri <- result.try( 97 104 json_util.field_string(input, "uri") ··· 101 108 util.rkey_from_uri(client_uri) 102 109 |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }), 103 110 ) 104 - // Prefer nested `record` object if present 105 - let body = case json_util.object_field(input, "record") { 106 - Some(rec) -> dynamic_to_stored(rec, collection) 107 - None -> { 108 - // whole input minus uri 109 - dynamic_to_stored(input, collection) 110 - } 111 + let body = case extract_record_json(input_json) { 112 + Ok(rec) -> ensure_type(rec, collection) 113 + Error(_) -> ensure_type("{}", collection) 111 114 } 112 115 use uri <- result.try(records.upsert(db, did, collection, rkey, body, "")) 113 116 Ok( ··· 119 122 ) 120 123 } 121 124 122 - fn delete_record( 125 + fn delete_record_local( 123 126 db: Db, 124 127 did: String, 125 128 collection: String, ··· 138 141 Ok("{}") 139 142 } 140 143 144 + // --------------------------------------------------------------------------- 145 + // OAuth PDS proxy mode (also indexes locally after successful write) 146 + // --------------------------------------------------------------------------- 147 + 148 + fn create_record_pds( 149 + db: Db, 150 + identity: String, 151 + collection: String, 152 + input_json: String, 153 + ) -> Result(String, AppError) { 154 + let rkey = util.new_token("", 13) |> tidish 155 + let body = ensure_type(input_json, collection) 156 + use wr <- result.try(pds_write.create_record(identity, collection, body, rkey)) 157 + // Index for AppView queries 158 + let _ = records.upsert(db, identity_did(identity, wr.uri), collection, rkey, body, wr.cid) 159 + Ok( 160 + json.object([ 161 + #("uri", json.string(wr.uri)), 162 + #("cid", json.string(wr.cid)), 163 + #("commit", json.object([ 164 + #("cid", json.string(wr.cid)), 165 + #("rev", json.string("")), 166 + ])), 167 + ]) 168 + |> json.to_string, 169 + ) 170 + } 171 + 172 + fn put_record_pds( 173 + db: Db, 174 + identity: String, 175 + collection: String, 176 + input: Dynamic, 177 + input_json: String, 178 + ) -> Result(String, AppError) { 179 + use client_uri <- result.try( 180 + json_util.field_string(input, "uri") 181 + |> result.map_error(error.BadRequest), 182 + ) 183 + use rkey <- result.try( 184 + util.rkey_from_uri(client_uri) 185 + |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }), 186 + ) 187 + let body = case extract_record_json(input_json) { 188 + Ok(rec) -> ensure_type(rec, collection) 189 + Error(_) -> ensure_type(input_json, collection) 190 + } 191 + use wr <- result.try(pds_write.put_record(identity, collection, rkey, body)) 192 + let _ = records.upsert(db, identity_did(identity, wr.uri), collection, rkey, body, wr.cid) 193 + Ok( 194 + json.object([ 195 + #("uri", json.string(wr.uri)), 196 + #("cid", json.string(wr.cid)), 197 + ]) 198 + |> json.to_string, 199 + ) 200 + } 201 + 202 + fn delete_record_pds( 203 + db: Db, 204 + identity: String, 205 + collection: String, 206 + input: Dynamic, 207 + ) -> Result(String, AppError) { 208 + use client_uri <- result.try( 209 + json_util.field_string(input, "uri") 210 + |> result.map_error(error.BadRequest), 211 + ) 212 + use rkey <- result.try( 213 + util.rkey_from_uri(client_uri) 214 + |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }), 215 + ) 216 + use _ <- result.try(pds_write.delete_record(identity, collection, rkey)) 217 + let uri = util.at_uri(identity, collection, rkey) 218 + let _ = records.delete(db, uri) 219 + // also try uri from client 220 + let _ = records.delete(db, client_uri) 221 + Ok("{}") 222 + } 223 + 224 + /// Prefer DID from AT URI when identity was a handle. 225 + fn identity_did(identity: String, uri: String) -> String { 226 + case string.starts_with(identity, "did:") { 227 + True -> identity 228 + False -> 229 + case string.split(uri, "/") { 230 + ["at:", "", did, ..] -> did 231 + _ -> identity 232 + } 233 + } 234 + } 235 + 141 236 fn ensure_type(input_json: String, collection: String) -> String { 142 237 case string.contains(input_json, "\"$type\"") { 143 238 True -> input_json ··· 145 240 case input_json { 146 241 "{" <> rest -> 147 242 "{\"$type\":" 148 - <> json.string(collection) |> json.to_string 243 + <> json.to_string(json.string(collection)) 149 244 <> case rest { 150 245 "}" -> "}" 151 246 _ -> "," <> rest ··· 155 250 } 156 251 } 157 252 158 - fn dynamic_to_stored(dyn: Dynamic, collection: String) -> String { 159 - // Prefer re-parsing known shape; fall back to ensuring $type on inspect is wrong. 160 - // Store a minimal object with $type when we can't re-encode Dynamic. 161 - case decode.run(dyn, decode.dict(decode.string, decode.dynamic)) { 162 - Ok(_) -> { 163 - // Can't easily re-encode arbitrary Dynamic — use gleam_json only for simple cases. 164 - // For MVP put path, require callers to send full `record` as JSON string via 165 - // router keeping original body and extracting. 166 - ensure_type("{}", collection) 253 + /// Pull nested `"record":{...}` JSON object from a procedure body when present. 254 + fn extract_record_json(input_json: String) -> Result(String, Nil) { 255 + let needle = "\"record\":" 256 + case string.split_once(input_json, needle) { 257 + Error(_) -> Error(Nil) 258 + Ok(#(_, rest)) -> { 259 + let rest = string.trim_start(rest) 260 + case rest { 261 + "{" <> _ -> extract_balanced_object(rest) 262 + _ -> Error(Nil) 263 + } 167 264 } 168 - Error(_) -> ensure_type("{}", collection) 169 265 } 170 266 } 171 267 172 - /// Prefer original JSON string for create; used by router. 173 - pub fn create_from_json( 174 - db: Db, 175 - did: String, 176 - collection: String, 177 - input_json: String, 178 - ) -> Result(String, AppError) { 179 - create_record(db, did, collection, input_json) 268 + fn extract_balanced_object(s: String) -> Result(String, Nil) { 269 + do_bal(s, 0, False, False, "") 270 + } 271 + 272 + fn do_bal( 273 + rest: String, 274 + depth: Int, 275 + in_str: Bool, 276 + escape: Bool, 277 + acc: String, 278 + ) -> Result(String, Nil) { 279 + case string.pop_grapheme(rest) { 280 + Error(_) -> Error(Nil) 281 + Ok(#(ch, rest2)) -> { 282 + let acc2 = acc <> ch 283 + case in_str { 284 + True -> 285 + case escape { 286 + True -> do_bal(rest2, depth, True, False, acc2) 287 + False -> 288 + case ch { 289 + "\\" -> do_bal(rest2, depth, True, True, acc2) 290 + "\"" -> do_bal(rest2, depth, False, False, acc2) 291 + _ -> do_bal(rest2, depth, True, False, acc2) 292 + } 293 + } 294 + False -> 295 + case ch { 296 + "\"" -> do_bal(rest2, depth, True, False, acc2) 297 + "{" -> do_bal(rest2, depth + 1, False, False, acc2) 298 + "}" -> { 299 + let d2 = depth - 1 300 + case d2 == 0 { 301 + True -> Ok(acc2) 302 + False -> do_bal(rest2, d2, False, False, acc2) 303 + } 304 + } 305 + _ -> do_bal(rest2, depth, False, False, acc2) 306 + } 307 + } 308 + } 309 + } 180 310 } 181 311 182 312 fn tidish(s: String) -> String { 183 - // TID-ish: strip prefix if empty token had leading junk 184 313 case string.starts_with(s, "_") { 185 314 True -> string.drop_start(s, 1) 186 315 False -> s
+250
src/gleamview_ffi.erl
··· 1 + -module(gleamview_ffi). 2 + -export([ 3 + open_browser/1, 4 + oauth_can_bind/1, 5 + oauth_wait_callback/2, 6 + dpop_generate_keypair/0, 7 + dpop_sign_jwt/3, 8 + base64url_encode/1, 9 + base64url_decode/1, 10 + random_bytes/1, 11 + sha256/1 12 + ]). 13 + 14 + 15 + reason_bin(Reason) -> 16 + unicode:characters_to_binary(io_lib:format("~p", [Reason])). 17 + 18 + %% --------------------------------------------------------------------------- 19 + %% OAuth / DPoP helpers 20 + %% --------------------------------------------------------------------------- 21 + 22 + open_browser(Url) when is_binary(Url) -> 23 + Target = binary_to_list(Url), 24 + Cmds = 25 + case os:type() of 26 + {unix, darwin} -> [{"open", [Target]}]; 27 + {win32, _} -> [{"cmd", ["/c", "start", "", Target]}]; 28 + _ -> [{"xdg-open", [Target]}] 29 + end, 30 + open_browser_try(Cmds). 31 + 32 + open_browser_try([]) -> 33 + {error, <<"no browser opener available">>}; 34 + open_browser_try([{Cmd, Args} | Rest]) -> 35 + case os:find_executable(Cmd) of 36 + false -> 37 + open_browser_try(Rest); 38 + Path -> 39 + try 40 + Port = open_port( 41 + {spawn_executable, Path}, 42 + [{args, Args}, hide, binary, exit_status] 43 + ), 44 + spawn(fun() -> drain_port(Port) end), 45 + {ok, nil} 46 + catch 47 + _:_ -> open_browser_try(Rest) 48 + end 49 + end. 50 + 51 + drain_port(Port) -> 52 + receive 53 + {Port, {exit_status, _}} -> ok; 54 + {Port, _} -> drain_port(Port) 55 + after 5000 -> 56 + catch port_close(Port), 57 + ok 58 + end. 59 + 60 + %% Probe whether we can bind 127.0.0.1:Port (closes immediately). 61 + oauth_can_bind(Port) when is_integer(Port) -> 62 + case 63 + gen_tcp:listen(Port, [ 64 + binary, 65 + {packet, raw}, 66 + {active, false}, 67 + {reuseaddr, true}, 68 + {ip, {127, 0, 0, 1}}, 69 + {backlog, 1} 70 + ]) 71 + of 72 + {ok, Listen} -> 73 + gen_tcp:close(Listen), 74 + true; 75 + {error, _} -> 76 + false 77 + end. 78 + 79 + %% Listen on 127.0.0.1:Port, wait for one HTTP request, return query string. 80 + %% TimeoutMs is the overall wait budget. 81 + oauth_wait_callback(Port, TimeoutMs) when is_integer(Port), is_integer(TimeoutMs) -> 82 + case 83 + gen_tcp:listen(Port, [ 84 + binary, 85 + {packet, raw}, 86 + {active, false}, 87 + {reuseaddr, true}, 88 + {ip, {127, 0, 0, 1}}, 89 + {backlog, 1} 90 + ]) 91 + of 92 + {error, Reason} -> 93 + {error, reason_bin(Reason)}; 94 + {ok, Listen} -> 95 + try 96 + case gen_tcp:accept(Listen, TimeoutMs) of 97 + {error, Reason} -> 98 + {error, reason_bin(Reason)}; 99 + {ok, Sock} -> 100 + case recv_http_request(Sock, <<>>, TimeoutMs) of 101 + {error, Reason} -> 102 + gen_tcp:close(Sock), 103 + {error, Reason}; 104 + {ok, PathQuery} -> 105 + Body = 106 + <<"HTTP/1.1 200 OK\r\n", 107 + "content-type: text/plain; charset=utf-8\r\n", 108 + "connection: close\r\n", 109 + "content-length: 48\r\n", 110 + "\r\n", 111 + "Authentication complete. You can close this window.">>, 112 + _ = gen_tcp:send(Sock, Body), 113 + gen_tcp:close(Sock), 114 + {ok, PathQuery} 115 + end 116 + end 117 + after 118 + gen_tcp:close(Listen) 119 + end 120 + end. 121 + 122 + recv_http_request(Sock, Acc, TimeoutMs) -> 123 + case gen_tcp:recv(Sock, 0, min(TimeoutMs, 30_000)) of 124 + {error, Reason} -> 125 + {error, reason_bin(Reason)}; 126 + {ok, Data} -> 127 + Buf = <<Acc/binary, Data/binary>>, 128 + case binary:match(Buf, <<"\r\n\r\n">>) of 129 + nomatch when byte_size(Buf) > 65536 -> 130 + {error, <<"HTTP request too large">>}; 131 + nomatch -> 132 + recv_http_request(Sock, Buf, TimeoutMs); 133 + {Pos, _} -> 134 + Header = binary:part(Buf, 0, Pos), 135 + case parse_request_line(Header) of 136 + {error, _} = E -> E; 137 + {ok, PathQuery} -> {ok, PathQuery} 138 + end 139 + end 140 + end. 141 + 142 + parse_request_line(Header) -> 143 + case binary:split(Header, <<"\r\n">>) of 144 + [Line | _] -> 145 + case binary:split(Line, <<" ">>, [global]) of 146 + [_Method, PathQuery | _] -> {ok, PathQuery}; 147 + _ -> {error, <<"malformed request line">>} 148 + end; 149 + _ -> 150 + {error, <<"empty request">>} 151 + end. 152 + 153 + %% Generate P-256 keypair for DPoP. Returns {ok, {D, X, Y}} as base64url binaries. 154 + dpop_generate_keypair() -> 155 + {Pub, Priv} = crypto:generate_key(ecdh, secp256r1), 156 + <<4, X:32/binary, Y:32/binary>> = Pub, 157 + D = 158 + case Priv of 159 + <<D0:32/binary>> -> D0; 160 + _ when is_binary(Priv), byte_size(Priv) =< 32 -> 161 + pad32(Priv); 162 + _ -> 163 + error({bad_priv, Priv}) 164 + end, 165 + {ok, {base64url_encode(D), base64url_encode(X), base64url_encode(Y)}}. 166 + 167 + %% Sign JWT signing-input (header.payload) with ES256 using private key D (base64url). 168 + %% Public X,Y also base64url — used only for header jwk construction by Gleam. 169 + %% Returns base64url signature (R||S, 64 bytes). 170 + dpop_sign_jwt(SigningInput, DB64, _XYIgnored) when is_binary(SigningInput), is_binary(DB64) -> 171 + try 172 + D = base64url_decode_strict(DB64), 173 + D32 = pad32(D), 174 + Der = crypto:sign(ecdsa, sha256, SigningInput, [D32, secp256r1]), 175 + Raw = der_ecdsa_to_raw(Der), 176 + {ok, base64url_encode(Raw)} 177 + catch 178 + C:E:S -> 179 + {error, reason_bin({C, E, S})} 180 + end. 181 + 182 + base64url_encode(Bin) when is_binary(Bin) -> 183 + Enc = base64:encode(Bin, #{mode => urlsafe, padding => false}), 184 + case is_binary(Enc) of 185 + true -> Enc; 186 + false -> unicode:characters_to_binary(Enc) 187 + end. 188 + 189 + base64url_decode(Bin) when is_binary(Bin) -> 190 + try 191 + {ok, base64url_decode_strict(Bin)} 192 + catch 193 + _:_ -> {error, nil} 194 + end. 195 + 196 + base64url_decode_strict(Bin) -> 197 + %% OTP base64 urlsafe may need padding restored 198 + Padded = pad_b64(Bin), 199 + Decoded = base64:decode(Padded, #{mode => urlsafe}), 200 + case Decoded of 201 + B when is_binary(B) -> B; 202 + {ok, B} when is_binary(B) -> B; 203 + Other -> error({bad_base64, Other}) 204 + end. 205 + 206 + pad_b64(Bin) -> 207 + case byte_size(Bin) rem 4 of 208 + 0 -> Bin; 209 + 2 -> <<Bin/binary, "==">>; 210 + 3 -> <<Bin/binary, "=">>; 211 + 1 -> <<Bin/binary, "===">> 212 + end. 213 + 214 + random_bytes(N) when is_integer(N), N > 0 -> 215 + crypto:strong_rand_bytes(N). 216 + 217 + sha256(Bin) when is_binary(Bin) -> 218 + crypto:hash(sha256, Bin). 219 + 220 + pad32(Bin) when byte_size(Bin) =:= 32 -> 221 + Bin; 222 + pad32(Bin) when byte_size(Bin) < 32 -> 223 + Pad = 32 - byte_size(Bin), 224 + <<0:(Pad * 8), Bin/binary>>; 225 + pad32(Bin) when byte_size(Bin) > 32 -> 226 + binary:part(Bin, byte_size(Bin) - 32, 32). 227 + 228 + %% Convert DER ECDSA signature to IEEE P1363 R||S (32+32 bytes). 229 + der_ecdsa_to_raw(<<16#30, _Len, Rest0/binary>>) -> 230 + {R, Rest1} = der_parse_int(Rest0), 231 + {S, _} = der_parse_int(Rest1), 232 + <<(pad32(R))/binary, (pad32(S))/binary>>; 233 + der_ecdsa_to_raw(Other) -> 234 + error({bad_der_sig, Other}). 235 + 236 + der_parse_int(<<16#02, Len, Rest/binary>>) when Len > 0, byte_size(Rest) >= Len -> 237 + <<Int:Len/binary, Tail/binary>> = Rest, 238 + %% INTEGER may have a leading 0x00 for positive values with high bit set 239 + Clean = strip_der_int_padding(Int), 240 + {Clean, Tail}; 241 + der_parse_int(Other) -> 242 + error({bad_der_int, Other}). 243 + 244 + strip_der_int_padding(<<0, RestInt/binary>>) when byte_size(RestInt) > 0 -> 245 + case binary:first(RestInt) band 16#80 of 246 + 0 -> <<0, RestInt/binary>>; 247 + _ -> RestInt 248 + end; 249 + strip_der_int_padding(Int) -> 250 + Int.
+12
test/gleamview_test.gleam
··· 3 3 import gleeunit 4 4 import gleamview/jetstream 5 5 import gleamview/lexicon 6 + import gleamview/oauth 6 7 import gleamview/util 7 8 8 9 pub fn main() { ··· 79 80 assert a == b 80 81 assert string.length(a) == 64 81 82 } 83 + 84 + pub fn oauth_loopback_client_id_test() { 85 + let id = 86 + oauth.loopback_client_id( 87 + "https://gleamview.boxd.sh/oauth/callback", 88 + oauth.default_scope, 89 + ) 90 + assert string.starts_with(id, "http://localhost?") 91 + assert string.contains(id, "redirect_uri=") 92 + assert string.contains(id, "scope=") 93 + }