| … | … | @@ -25,7 +25,7 @@ import { join } from 'bare-path' |
| 25 | 25 | import { discoveryTopic } from 'atproto-identity/lib/discovery.js' |
| 26 | 26 | import { decodeFrame } from 'atproto-store/lib/firehose.js' |
| 27 | 27 | import { Appview, INGEST_VERSION, threadPostList } from '../lib/appview.js' |
| 28 | import { parseLenses, lensesEnabled } from '../lib/lenses.js' | |
| 28 | import { parseLenses, lensesEnabled, lensEnv, parseLensPref, LENS_PREF_FILE } from '../lib/lenses.js' | |
| 29 | 29 | import { createLensReader, extractThreadReplies, extractThreadParents } from '../lib/lens-import.js' |
| 30 | 30 | import { frontsDiag } from '../lib/fronts-diag.js' |
| 31 | 31 | import { fromApItem, fromReply, mergeNotifications } from '../lib/notifications.js' |
| … | … | @@ -138,13 +138,20 @@ const WRITE_SEED = process.env.FAVELA_WRITER_SEED || 'wj62-swarm-test-writer-202 |
| 138 | 138 | // THROUGH our own bridge so no bsky URL or credential lands in this config) or FAVELA_LENSES. |
| 139 | 139 | // Malformed entries are reported and skipped rather than fatal — a typo in one lens must not stop the |
| 140 | 140 | // daemon booting, but it must not vanish silently either. |
| 141 | const { lenses: LENSES, errors: LENS_ERRORS } = parseLenses(process.env) | |
| 141 | // The stored preference (settings page) layered over env. `let`, not `const`, because saving in the UI | |
| 142 | // rebuilds these in place — a setting that needs a daemon restart to take effect is a config file with | |
| 143 | // extra steps, and on a server nobody is going to restart for it. | |
| 144 | function readLensPref () { | |
| 145 | try { return JSON.parse(fs.readFileSync(STORE(LENS_PREF_FILE), 'utf8')) } catch { return null } | |
| 146 | } | |
| 147 | let LENS_PREF = readLensPref() | |
| 148 | let { lenses: LENSES, errors: LENS_ERRORS } = parseLenses(lensEnv(process.env, LENS_PREF)) | |
| 142 | 149 | for (const e of LENS_ERRORS) console.error(JSON.stringify({ msg: 'lens config problem — entry ignored', error: e })) |
| 143 | const LENSES_ON = lensesEnabled(LENSES) | |
| 150 | let LENSES_ON = lensesEnabled(LENSES) | |
| 144 | 151 | // One reader for the process: it owns the cache, the single-flight coalescing, the per-lens circuit |
| 145 | 152 | // breaker and the concurrency cap, so those are properties of THIS NODE rather than of how many people |
| 146 | 153 | // happen to open a thread at once. |
| 147 | const lensReader = LENSES_ON ? createLensReader({ doFetch: bareFetch }) : null | |
| 154 | let lensReader = LENSES_ON ? createLensReader({ doFetch: bareFetch }) : null | |
| 148 | 155 | console.log(JSON.stringify({ msg: 'lens import', enabled: LENSES_ON, lenses: LENSES.map((l) => l.name) })) |
| 149 | 156 | |
| 150 | 157 | const LIST = (process.env.FAVELA_LIST || '').trim() |
| … | … | @@ -1252,6 +1259,10 @@ const OPEN_POST = new Set(['/api/auth/login', '/api/auth/pair', '/api/auth/logou |
| 1252 | 1259 | // so repo ownership is not the relevant question, but anonymity still is. |
| 1253 | 1260 | const IDENTITY_POST = new Set([ |
| 1254 | 1261 | '/api/devices/pair', '/api/devices/invite', '/api/devices/revoke', |
| 1262 | // /api/lens changes where THIS NODE reads from — node-local, nothing signed into a repo, so repo | |
| 1263 | // ownership is not the question. It carries its own stricter check (viewer must be the identity this | |
| 1264 | // node signs for), because repointing a node's reads is an operator act, not a viewer preference. | |
| 1265 | '/api/lens', | |
| 1255 | 1266 | '/api/track', '/api/bookmark', '/api/mute' |
| 1256 | 1267 | ]) |
| 1257 | 1268 | // Everything else POSTed authors a record (or a blob) into the repo and falls through to the |
| … | … | @@ -1528,7 +1539,12 @@ const server = http.createServer(async (req, res) => { |
| 1528 | 1539 | if (process.env.FAVELA_SHELL_STATE) { |
| 1529 | 1540 | try { shell = JSON.parse(fs.readFileSync(process.env.FAVELA_SHELL_STATE, 'utf8')) } catch { shell = null } |
| 1530 | 1541 | } |
| 1531 | return html(res, 200, settingsPage({ viewer, profile, did, devices, theme: res._theme, shell })) | |
| 1542 | // The lens control is shown only to the identity this node signs for — the same test /api/lens | |
| 1543 | // itself enforces, so the page never offers a control the route will refuse. | |
| 1544 | let isOperator = false | |
| 1545 | if (viewer) { try { const st = await writer.state(); isOperator = !!(st && st.did && st.did === viewer.did) } catch {} } | |
| 1546 | const lens = { enabled: LENSES_ON, lenses: LENSES.map((l) => ({ name: l.name, base: l.base })) } | |
| 1547 | return html(res, 200, settingsPage({ viewer, profile, did, devices, theme: res._theme, shell, lens, isOperator })) | |
| 1532 | 1548 | } |
| 1533 | 1549 | if (path === '/login') { |
| 1534 | 1550 | // Sign-in UI: the client script (/static/login.js) generates the browser's secp256k1 sub-key, |
| … | … | @@ -2170,7 +2186,11 @@ const server = http.createServer(async (req, res) => { |
| 2170 | 2186 | } |
| 2171 | 2187 | // Lens config + live reader counters (cache hits, failures, open breakers). The boot log said only |
| 2172 | 2188 | // whether lenses were enabled; this says whether they are WORKING. |
| 2173 | if (path === '/api/lens') { | |
| 2189 | // | |
| 2190 | // ⛔ EXCLUDE POST, or this swallows it. This route carries no method check and sits ~250 lines ahead | |
| 2191 | // of the POST handler that SETS the lens, so without the guard the setter is unreachable code that | |
| 2192 | // returns diagnostics and looks like a successful save. | |
| 2193 | if (path === '/api/lens' && req.method !== 'POST') { | |
| 2174 | 2194 | return json(res, 200, JSON.stringify({ |
| 2175 | 2195 | enabled: LENSES_ON, |
| 2176 | 2196 | lenses: LENSES.map((l) => ({ name: l.name, base: l.base, proxy: l.proxy || null })), |
| … | … | @@ -2422,6 +2442,48 @@ const server = http.createServer(async (req, res) => { |
| 2422 | 2442 | } |
| 2423 | 2443 | |
| 2424 | 2444 | // Follow / unfollow a DID. Same toggle shape. A follow is what scopes the Following feed below. |
| 2445 | // ── the lens setting, from the settings page ───────────────────────────────────────────────── | |
| 2446 | // Which remote AppView this node may read was reachable only by editing apps.json and restarting, | |
| 2447 | // which makes it a deployment detail rather than a setting — and on a handset there is no apps.json | |
| 2448 | // at all, so it could not be changed there by any means. | |
| 2449 | // | |
| 2450 | // ⛔ OPERATOR ONLY. This decides where this node sends the DIDs it looks up, so it is gated the same | |
| 2451 | // way /api/follow is: the viewer must be the identity this node signs for. A signed-in guest on | |
| 2452 | // somebody else's instance must not be able to repoint its reads. | |
| 2453 | // | |
| 2454 | // Applied IN PLACE. A setting that needs a daemon restart is a config file with extra steps. | |
| 2455 | if (req.method === 'POST' && path === '/api/lens') { | |
| 2456 | const isForm = !(req.headers['content-type'] || '').includes('application/json') | |
| 2457 | const raw = (await readBody(req)) || '' | |
| 2458 | let value, back | |
| 2459 | try { | |
| 2460 | if (isForm) { const q = new URLSearchParams(raw); value = q.get('bridgeUrl'); back = q.get('return') } | |
| 2461 | else { const b = JSON.parse(raw || '{}'); value = b.bridgeUrl; back = b.return } | |
| 2462 | } catch { value = null } | |
| 2463 | const home = safeReturn(back) || '/settings' | |
| 2464 | const fail = (code, msg) => isForm ? redirect(res, home) : json(res, code, JSON.stringify({ error: msg })) | |
| 2465 | if (!viewer) return fail(401, 'sign in first') | |
| 2466 | const st = await writer.state().catch(() => null) | |
| 2467 | if (!st || !st.did || st.did !== viewer.did) return fail(403, 'only this node\'s own identity can change where it reads') | |
| 2468 | const parsed = parseLensPref(value) | |
| 2469 | if (parsed.error) return fail(400, parsed.error) | |
| 2470 | try { | |
| 2471 | fs.writeFileSync(STORE(LENS_PREF_FILE), JSON.stringify({ bridgeUrl: parsed.bridgeUrl }, null, 2) + '\n') | |
| 2472 | LENS_PREF = { bridgeUrl: parsed.bridgeUrl } | |
| 2473 | const next = parseLenses(lensEnv(process.env, LENS_PREF)) | |
| 2474 | for (const e of next.errors) console.error(JSON.stringify({ msg: 'lens config problem — entry ignored', error: e })) | |
| 2475 | LENSES = next.lenses | |
| 2476 | LENS_ERRORS = next.errors // or /api/lens keeps reporting the errors from boot | |
| 2477 | LENSES_ON = lensesEnabled(LENSES) | |
| 2478 | // Rebuilt rather than kept: the reader owns the cache, the circuit breaker and the concurrency | |
| 2479 | // cap, and carrying those across a change of source would answer the new lens from the old one. | |
| 2480 | lensReader = LENSES_ON ? createLensReader({ doFetch: bareFetch }) : null | |
| 2481 | console.log(JSON.stringify({ msg: 'lens import reconfigured', enabled: LENSES_ON, lenses: LENSES.map((l) => l.name) })) | |
| 2482 | } catch (e) { return fail(502, 'could not save: ' + e.message) } | |
| 2483 | if (isForm) return redirect(res, home) | |
| 2484 | return json(res, 200, JSON.stringify({ ok: true, enabled: LENSES_ON, lenses: LENSES.map((l) => l.name) })) | |
| 2485 | } | |
| 2486 | ||
| 2425 | 2487 | if (req.method === 'POST' && path === '/api/follow') { |
| 2426 | 2488 | const isForm = !(req.headers['content-type'] || '').includes('application/json') |
| 2427 | 2489 | const raw = (await readBody(req)) || '' |
| … | … | @@ -6,7 +6,14 @@ |
| 6 | 6 | // opts INTO. The cost of opting out is that a thread's view is partial, which the UI must say out loud |
| 7 | 7 | // rather than leave implied (a thread with three unseen bsky replies looks identical to one with none). |
| 8 | 8 | // |
| 9 | // Two config forms, both env-driven: | |
| 9 | // Three config forms. The first is the one a person can actually reach: | |
| 10 | // | |
| 11 | // THE SETTINGS PAGE (lensPref below) | |
| 12 | // A node-level trust decision that only someone with shell access could change is not a setting, it | |
| 13 | // is a deployment detail. The stored preference wins over env so that changing it in the UI is not | |
| 14 | // silently overridden by whatever the process was started with, and clearing it falls back to env. | |
| 15 | // | |
| 16 | // The env forms remain, for provisioning a node before anyone opens it: | |
| 10 | 17 | // |
| 11 | 18 | // FAVELA_BRIDGE_URL=https://pds.example.net |
| 12 | 19 | // Shorthand for the common case: one lens named 'bsky', reached THROUGH the bridge, which resolves |
| … | … | @@ -22,7 +29,7 @@ |
| 22 | 29 | |
| 23 | 30 | const BSKY_APPVIEW_PROXY = 'did:web:api.bsky.app#bsky_appview' |
| 24 | 31 | |
| 25 | function validBase (base) { | |
| 32 | export function validBase (base) { | |
| 26 | 33 | const s = String(base || '') |
| 27 | 34 | return /^https?:\/\/[^\s/]+/.test(s) ? s.replace(/\/$/, '') : null |
| 28 | 35 | } |
| … | … | @@ -83,3 +90,34 @@ export function lensesEnabled (lenses) { |
| 83 | 90 | export function findLens (lenses, name) { |
| 84 | 91 | return (Array.isArray(lenses) ? lenses : []).find((l) => l.name === name) || null |
| 85 | 92 | } |
| 93 | ||
| 94 | ||
| 95 | // ── the stored preference ──────────────────────────────────────────────────────────────────────────── | |
| 96 | // Node-LOCAL on purpose, unlike the subscription list, which is a property of the identity and belongs in | |
| 97 | // its repo. Which remote AppView this node may read is a trust and privacy decision about THIS machine on | |
| 98 | // THIS network: a phone on a hostile network can reasonably answer differently from a server, and a fresh | |
| 99 | // install must start closed regardless of what any other device chose. | |
| 100 | // | |
| 101 | // Pure: the caller owns the file I/O, so the precedence rule is testable without a store. | |
| 102 | export const LENS_PREF_FILE = 'lens-pref.json' | |
| 103 | ||
| 104 | /** | |
| 105 | * Merge a stored preference into an env-like object, for parseLenses. | |
| 106 | * | |
| 107 | * Stored wins when set — otherwise saving in the UI would appear to do nothing on a node started with | |
| 108 | * FAVELA_BRIDGE_URL. An explicitly cleared preference ('') means "off", NOT "fall back to env", or the | |
| 109 | * UI could never turn off what env turned on. | |
| 110 | */ | |
| 111 | export function lensEnv (env = {}, pref = null) { | |
| 112 | if (!pref || typeof pref !== 'object' || pref.bridgeUrl === undefined) return env | |
| 113 | return { ...env, FAVELA_BRIDGE_URL: pref.bridgeUrl || '' } | |
| 114 | } | |
| 115 | ||
| 116 | /** Validate what the settings form submitted. Returns { bridgeUrl } or { error }. */ | |
| 117 | export function parseLensPref (raw) { | |
| 118 | const v = String(raw == null ? '' : raw).trim() | |
| 119 | if (!v) return { bridgeUrl: '' } // cleared — reading remotely is off | |
| 120 | const base = validBase(v) | |
| 121 | if (!base) return { error: 'that is not an http(s) url' } | |
| 122 | return { bridgeUrl: base } | |
| 123 | } |
| … | … | @@ -1290,12 +1290,48 @@ document.querySelectorAll('[data-ota="pair"]').forEach(function (b) { |
| 1290 | 1290 | </section>` |
| 1291 | 1291 | } |
| 1292 | 1292 | |
| 1293 | export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null, shell = null } = {}) { | |
| 1293 | // Where this node may read from, as a setting rather than a deployment detail. | |
| 1294 | // | |
| 1295 | // ⛔ THIS WAS ONLY REACHABLE BY EDITING apps.json AND RESTARTING — and on a handset there is no | |
| 1296 | // apps.json at all, so it could not be changed there by any means. The consequence was visible rather | |
| 1297 | // than theoretical: a bsky account's page rendered a real name and avatar and "no posts from this | |
| 1298 | // author", because reading their posts needs a lens and no node had one configured (hyper-social #53). | |
| 1299 | // | |
| 1300 | // Shown to the node's OWN identity only. Repointing where a node sends the DIDs it looks up is an | |
| 1301 | // operator act, not a viewer preference, and on a public instance a signed-in guest must not have it. | |
| 1302 | // | |
| 1303 | // A plain form: it works with scripting off, like themeControl and the profile form. | |
| 1304 | export function lensControl ({ lens = null, isOperator = false, returnTo = '/settings' } = {}) { | |
| 1305 | if (!isOperator) return '' | |
| 1306 | const on = !!(lens && lens.enabled) | |
| 1307 | const current = (lens && lens.lenses && lens.lenses[0] && lens.lenses[0].base) || '' | |
| 1308 | const names = (lens && lens.lenses ? lens.lenses : []).map((l) => l.name).filter(Boolean) | |
| 1309 | const state = on | |
| 1310 | ? `<p class="empty">Reading from <strong>${esc(names.join(', ') || 'a lens')}</strong> at <code>${esc(current)}</code>.</p>` | |
| 1311 | : '<p class="empty">Off. Posts by people this node does not index \u2014 anyone on bsky \u2014 show as an empty profile.</p>' | |
| 1312 | return `<section class="authbox lenssec"> | |
| 1313 | <h3>Remote reading</h3> | |
| 1314 | <p class="empty">Point this at a bridge and profiles this node has never indexed can show their posts, | |
| 1315 | clearly marked as coming from that source. Nothing is stored and nothing is federated \u2014 it fills | |
| 1316 | in a page that would otherwise be blank. Leave it empty to read nothing remote, the default.</p> | |
| 1317 | ${state} | |
| 1318 | <form method="POST" action="/api/lens" class="rowform"> | |
| 1319 | <input type="hidden" name="return" value="${esc(returnTo)}"> | |
| 1320 | <p><label for="lens-url">bridge url</label><br> | |
| 1321 | <input id="lens-url" name="bridgeUrl" type="url" inputmode="url" placeholder="https://pds.example.net" | |
| 1322 | value="${esc(current)}" size="34"></p> | |
| 1323 | <p><button type="submit">save</button> | |
| 1324 | <span class="empty">applies immediately \u2014 no restart</span></p> | |
| 1325 | </form> | |
| 1326 | </section>` | |
| 1327 | } | |
| 1328 | ||
| 1329 | export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null, shell = null, lens = null, isOperator = false } = {}) { | |
| 1294 | 1330 | if (!viewer) { |
| 1295 | 1331 | // Appearance still renders here — it is not an account setting. So does App updates: a restart is |
| 1296 | 1332 | // what repairs a session this page has forgotten, and hiding it behind sign-in hides it exactly when |
| 1297 | 1333 | // it is needed. |
| 1298 | return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + appUpdateSection(shell) + pairSection(shell) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' }) | |
| 1334 | return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + appUpdateSection(shell) + pairSection(shell) + lensControl({ lens, isOperator }) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' }) | |
| 1299 | 1335 | } |
| 1300 | 1336 | const cid = profile && profile.avatar && blobCid(profile.avatar.ref) |
| 1301 | 1337 | const current = (cid && did && isSafeCid(cid)) |
| … | … | @@ -1322,6 +1358,7 @@ ${themeControl(theme)} |
| 1322 | 1358 | ${versionNote()} |
| 1323 | 1359 | ${appUpdateSection(shell)} |
| 1324 | 1360 | ${pairSection(shell)} |
| 1361 | ${lensControl({ lens, isOperator })} | |
| 1325 | 1362 | <section class="authbox"> |
| 1326 | 1363 | <h3>Profile picture</h3> |
| 1327 | 1364 | <p class="empty">Signed in as <strong>${esc(viewer.did)}</strong>${profile && profile.displayName ? ' · ' + esc(profile.displayName) : ''}</p> |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-web", |
| 3 | "version": "0.3.86", | |
| 3 | "version": "0.3.87", | |
| 4 | 4 | "description": "favela-web — the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "bin/favela-web.js", |
| … | … | @@ -1,5 +1,5 @@ |
| 1 | 1 | // favela SSR render layer — pure, runtime-agnostic (no P2P/index/Bare). Run: node test/render.test.mjs |
| 2 | import { pairSection, withReach, renderReach, esc, renderText, renderPost, renderPosts, renderEmbed, blobCid, timelinePage, profilePage, tagPage, renderAtom, settingsPage, postPage, relTime, shortDid, freshnessNote, postHref, renderRichText, withViewer, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, emptyState, appUpdateSection } from '../lib/render.js' | |
| 2 | import { pairSection, lensControl, withReach, renderReach, esc, renderText, renderPost, renderPosts, renderEmbed, blobCid, timelinePage, profilePage, tagPage, renderAtom, settingsPage, postPage, relTime, shortDid, freshnessNote, postHref, renderRichText, withViewer, searchPage, bookmarksPage, notificationsPage, listsPage, listPage, followListPage, emptyState, appUpdateSection } from '../lib/render.js' | |
| 3 | 3 | import { SCOPE_COLLECTION } from '../lib/scopes.js' |
| 4 | 4 | |
| 5 | 5 | let pass = 0 |
| … | … | @@ -971,3 +971,31 @@ function t_is_empty (s, m) { ok(s === '', m) } |
| 971 | 971 | } |
| 972 | 972 | |
| 973 | 973 | console.log(`\nRENDER OK: ${pass} assertions`) |
| 974 | ||
| 975 | ||
| 976 | // ── the lens setting, which used to be reachable only by editing apps.json ──────────────────────── | |
| 977 | // A node-level trust decision that needs shell access is a deployment detail, not a setting — and on a | |
| 978 | // handset there is no apps.json at all. The visible consequence was a bsky account rendering a real name | |
| 979 | // and avatar with "no posts from this author" (hyper-social #53). | |
| 980 | { | |
| 981 | const on = { enabled: true, lenses: [{ name: 'bsky', base: 'https://pds.7rnx.net' }] } | |
| 982 | ||
| 983 | // ⛔ Repointing where a node sends the DIDs it looks up is an OPERATOR act. On a public instance a | |
| 984 | // signed-in guest must never see this, and the page must not offer a control /api/lens would refuse. | |
| 985 | ok(lensControl({ lens: on, isOperator: false }) === '', 'a viewer who is not this node\'s identity sees nothing') | |
| 986 | ok(lensControl({ lens: on }) === '', 'and absent by default — not shown unless proven operator') | |
| 987 | ||
| 988 | const shown = lensControl({ lens: on, isOperator: true }) | |
| 989 | has(shown, 'Remote reading', 'the operator gets the control') | |
| 990 | has(shown, 'https://pds.7rnx.net', 'with the current bridge filled in, so saving does not blank it') | |
| 991 | has(shown, 'action="/api/lens"', 'posting to the route that applies it') | |
| 992 | has(shown, 'method="POST"', 'a plain form — works with scripting off') | |
| 993 | ||
| 994 | const off = lensControl({ lens: { enabled: false, lenses: [] }, isOperator: true }) | |
| 995 | has(off, 'Off.', 'the off state says so') | |
| 996 | has(off, 'empty profile', 'and names the consequence rather than leaving it implied') | |
| 997 | ||
| 998 | // It reaches the real page, and only for the operator. | |
| 999 | has(settingsPage({ viewer: { did: 'did:plc:alice' }, lens: on, isOperator: true }), 'Remote reading', 'rendered on the settings page') | |
| 1000 | not(settingsPage({ viewer: { did: 'did:plc:alice' }, lens: on, isOperator: false }), 'Remote reading', 'and never for a guest') | |
| 1001 | } |