| … | … | @@ -49,6 +49,55 @@ export function parseResolvConf (text) { |
| 49 | 49 | return out |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | // ⛔ ANDROID HAS NO /etc/resolv.conf. netd owns resolution there and `getprop net.dns1` has been empty | |
| 53 | // since Android 8, so the handset booted with `DNS TXT handle verification is OFF` — the bare-dns crash | |
| 54 | // was fixed and the CAPABILITY never arrived: every custom-domain handle renders handle.invalid on the | |
| 55 | // phone, which by our own measurement is most of the interesting identities on the network. | |
| 56 | // | |
| 57 | // Nothing about the resolver needed changing; it only ever needed somewhere to send the query. So the | |
| 58 | // embedder may pass one in. On Android that is ConnectivityManager's own DNS list for the active | |
| 59 | // network — the SAME resolver the OS uses, not a third party, which is why this is an env handoff and | |
| 60 | // NOT a hardcoded 1.1.1.1: handle verification is the last place to hand a stranger the list of every | |
| 61 | // identity we look up (see the note at the resolver's construction in bin/favela-web.js). | |
| 62 | // | |
| 63 | // Addresses only, no ports, no hostnames: this value is handed straight to connect(), and a hostname | |
| 64 | // would need a resolver to resolve — which is the thing we are trying to obtain. | |
| 65 | // | |
| 66 | // ⛔ A CHARACTER-CLASS TEST IS NOT ENOUGH. `[0-9a-fA-F:.]+` admits `8.8.8.8:53`, because ':' has to be | |
| 67 | // allowed for IPv6 — and a port smuggled into the host argument is a connect() to a host that does not | |
| 68 | // exist. So the two families are parsed apart. | |
| 69 | function isIpv4 (s) { | |
| 70 | const parts = s.split('.') | |
| 71 | return parts.length === 4 && parts.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255) | |
| 72 | } | |
| 73 | function isIpv6 (s) { | |
| 74 | if (!s.includes(':')) return false | |
| 75 | if ((s.match(/::/g) || []).length > 1) return false | |
| 76 | const parts = s.split(':') | |
| 77 | if (parts.length > 8) return false | |
| 78 | return parts.every((p, i) => { | |
| 79 | if (p === '') return true // the '::' compression, and a leading/trailing colon of it | |
| 80 | if (i === parts.length - 1 && p.includes('.')) return isIpv4(p) // ::ffff:1.2.3.4 | |
| 81 | return /^[0-9a-fA-F]{1,4}$/.test(p) | |
| 82 | }) | |
| 83 | } | |
| 84 | const isAddress = (s) => typeof s === 'string' && s.length > 0 && s.length <= 45 && (isIpv4(s) || isIpv6(s)) | |
| 85 | ||
| 86 | export function parseNameserverList (value) { | |
| 87 | if (typeof value !== 'string') return [] | |
| 88 | return value.split(/[,\s]+/).map((s) => s.trim()).filter(isAddress) | |
| 89 | } | |
| 90 | ||
| 91 | // Env first, resolv.conf second. Deliberately not "merge both": if an embedder has said which resolvers | |
| 92 | // to use, silently appending the file's would send some queries somewhere it did not choose. | |
| 93 | export function chooseNameservers ({ envValue = null, resolvConf = null } = {}) { | |
| 94 | const fromEnv = parseNameserverList(envValue) | |
| 95 | if (fromEnv.length) return { servers: fromEnv, source: 'env' } | |
| 96 | const fromFile = parseResolvConf(resolvConf) | |
| 97 | if (fromFile.length) return { servers: fromFile, source: 'resolv.conf' } | |
| 98 | return { servers: [], source: 'none' } | |
| 99 | } | |
| 100 | ||
| 52 | 101 | export function encodeQuery (name, id) { |
| 53 | 102 | if (typeof name !== 'string' || !name) throw new Error('name required') |
| 54 | 103 | const labels = name.replace(/\.$/, '').split('.') |
| … | … | @@ -1196,10 +1196,67 @@ export function versionNote () { |
| 1196 | 1196 | return `<p class="vernote">favela-web <code>${esc(VERSION)}</code></p>` |
| 1197 | 1197 | } |
| 1198 | 1198 | |
| 1199 | export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null } = {}) { | |
| 1199 | // App updates, on a handset only. Rendered ONLY when the native shell has said it can service these | |
| 1200 | // buttons (`shell.ota`), because the shell and the worklet bundle version INDEPENDENTLY — the bundle | |
| 1201 | // arrives by OTA, the shell only when an APK is installed — so a new bundle can find itself inside an | |
| 1202 | // old shell that has no message handler. Buttons that silently do nothing are worse than no buttons. | |
| 1203 | // | |
| 1204 | // ⛔ WHY THIS EXISTS AT ALL. Staging and applying are deliberately separate (applying to a running node | |
| 1205 | // means swapping code under itself), so an update discovered during boot N can only take effect at boot | |
| 1206 | // N+1 — and with a 6-hour poll and no way to restart the node, "up to date" is a thing the phone can | |
| 1207 | // only become by accident. Measured on a handset 2026-08-24: the device ran a bundle whose replacement | |
| 1208 | // had been staged for over half an hour with no user-reachable way to apply it. | |
| 1209 | // | |
| 1210 | // This does NOT make updates event-driven; the 6h poll is still the last polling loop in the stack and | |
| 1211 | // announce-over-swarm is still the real fix. It makes the existing design operable by the person holding | |
| 1212 | // the phone. | |
| 1213 | // | |
| 1214 | // Signed OUT as well, on purpose: a stale session is one of the states a restart repairs, and gating the | |
| 1215 | // repair on being signed in would put it behind the thing that is broken. | |
| 1216 | export function appUpdateSection (shell = null) { | |
| 1217 | if (!shell || !shell.ota) return '' | |
| 1218 | const short = (h) => (typeof h === 'string' && h.length > 12 ? h.slice(0, 12) + '…' : (h || '')) | |
| 1219 | const running = shell.running ? `<code>${esc(short(shell.running))}</code>` : '<span class="empty">the bundle shipped in the app</span>' | |
| 1220 | // "staged" is only news when it is NOT what is already running — otherwise it reads as a pending | |
| 1221 | // update to someone who is already on it, which is the exact lie the OTA log used to tell. | |
| 1222 | const pending = shell.staged && shell.staged !== shell.running | |
| 1223 | const stagedRow = pending | |
| 1224 | ? `<p class="otapend">An update is staged: <code>${esc(short(shell.staged))}</code> — restart the node to run it.</p>` | |
| 1225 | : '<p class="empty">No update staged.</p>' | |
| 1226 | const checked = shell.checkedAt ? `<p class="empty">last checked ${esc(relTime(shell.checkedAt))}</p>` : '' | |
| 1227 | return `<section class="authbox otasec"> | |
| 1228 | <h3>App updates</h3> | |
| 1229 | <p class="empty">running <span class="otarun">${running}</span></p> | |
| 1230 | ${stagedRow} | |
| 1231 | ${checked} | |
| 1232 | <p><button class="devbtn" data-ota="check">check for updates</button> | |
| 1233 | <button class="devbtn${pending ? ' otaprim' : ''}" data-ota="restart">restart the node</button></p> | |
| 1234 | <p class="empty">Checking downloads in the background; nothing changes until the node restarts. A restart | |
| 1235 | also re-signs this page in, which is the fix if it has started refusing you.</p> | |
| 1236 | <style>.otapend{margin:.3rem 0}.otaprim{border-color:#2563eb}.otarun code{font-size:.85rem}</style> | |
| 1237 | <script> | |
| 1238 | // One-way to the shell, deliberately: it acts and then reloads this page, so the state above is always | |
| 1239 | // SERVER-rendered from what the worklet actually knows. A reply channel would be a second source of | |
| 1240 | // truth for the same three fields. | |
| 1241 | document.querySelectorAll('[data-ota]').forEach(function (b) { | |
| 1242 | b.addEventListener('click', function () { | |
| 1243 | var bridge = window.ReactNativeWebView | |
| 1244 | if (!bridge) return | |
| 1245 | b.disabled = true | |
| 1246 | b.textContent = b.dataset.ota === 'restart' ? 'restarting…' : 'checking…' | |
| 1247 | bridge.postMessage(JSON.stringify({ t: 'app-update', op: b.dataset.ota })) | |
| 1248 | }) | |
| 1249 | }) | |
| 1250 | </script> | |
| 1251 | </section>` | |
| 1252 | } | |
| 1253 | ||
| 1254 | export function settingsPage ({ viewer = null, profile = null, did = null, devices = [], theme = null, shell = null } = {}) { | |
| 1200 | 1255 | if (!viewer) { |
| 1201 | // Appearance still renders here — it is not an account setting. | |
| 1202 | return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' }) | |
| 1256 | // Appearance still renders here — it is not an account setting. So does App updates: a restart is | |
| 1257 | // what repairs a session this page has forgotten, and hiding it behind sign-in hides it exactly when | |
| 1258 | // it is needed. | |
| 1259 | return page({ title: 'settings · favela', viewer, active: null, theme, body: '<div class="viewhdr">Settings <a href="/">← back</a></div>' + themeControl(theme) + versionNote() + appUpdateSection(shell) + '<p class="empty">Sign in to manage your profile. <a href="/login">sign in →</a></p>' }) | |
| 1203 | 1260 | } |
| 1204 | 1261 | const cid = profile && profile.avatar && blobCid(profile.avatar.ref) |
| 1205 | 1262 | const current = (cid && did && isSafeCid(cid)) |
| … | … | @@ -1224,6 +1281,7 @@ export function settingsPage ({ viewer = null, profile = null, did = null, devic |
| 1224 | 1281 | body: `<div class="viewhdr">Settings <a href="/">← back</a></div> |
| 1225 | 1282 | ${themeControl(theme)} |
| 1226 | 1283 | ${versionNote()} |
| 1284 | ${appUpdateSection(shell)} | |
| 1227 | 1285 | <section class="authbox"> |
| 1228 | 1286 | <h3>Profile picture</h3> |
| 1229 | 1287 | <p class="empty">Signed in as <strong>${esc(viewer.did)}</strong>${profile && profile.displayName ? ' · ' + esc(profile.displayName) : ''}</p> |
| … | … | @@ -6,7 +6,8 @@ |
| 6 | 6 | // is a redirect, a 404 or nothing. So this codec is on the path for most real identities, and the |
| 7 | 7 | // tests that matter are the malformed-input ones: it parses bytes from the network. |
| 8 | 8 | import { |
| 9 | parseResolvConf, encodeQuery, decodeResponse, frame, createTcpResolveTxt, RCODE | |
| 9 | parseResolvConf, parseNameserverList, chooseNameservers, | |
| 10 | encodeQuery, decodeResponse, frame, createTcpResolveTxt, RCODE | |
| 10 | 11 | } from '../lib/dns-txt.js' |
| 11 | 12 | |
| 12 | 13 | let pass = 0 |
| … | … | @@ -19,6 +20,30 @@ ok(parseResolvConf('# nameserver 8.8.8.8\n').length === 0, 'comments are not nam |
| 19 | 20 | ok(JSON.stringify(parseResolvConf('nameserver 1.1.1.1\nnameserver 8.8.8.8')) === '["1.1.1.1","8.8.8.8"]', 'order is preserved') |
| 20 | 21 | ok(parseResolvConf(null).length === 0, 'garbage in, empty out') |
| 21 | 22 | |
| 23 | // ── where the servers come from ────────────────────────────────────────────────────────────────── | |
| 24 | // Android has no /etc/resolv.conf — netd owns resolution — so a handset found no nameserver and turned | |
| 25 | // TXT verification OFF, which by the measurement above means most identities render handle.invalid | |
| 26 | // there. FAVELA_NAMESERVERS is how an embedder passes the OS's own resolvers in. | |
| 27 | ok(JSON.stringify(parseNameserverList('192.168.1.1,8.8.8.8')) === '["192.168.1.1","8.8.8.8"]', 'a comma list is parsed') | |
| 28 | ok(JSON.stringify(parseNameserverList(' 1.1.1.1 8.8.4.4 ')) === '["1.1.1.1","8.8.4.4"]', 'whitespace separates too, and is trimmed') | |
| 29 | ok(parseNameserverList('2606:4700:4700::1111').length === 1, 'IPv6 is an address') | |
| 30 | // ⛔ Addresses only. This value is handed to connect(); a hostname would need a resolver to resolve, | |
| 31 | // which is the thing being obtained, and anything else is junk arriving at a socket call. | |
| 32 | ok(parseNameserverList('dns.google').length === 0, 'a hostname is not an address') | |
| 33 | ok(parseNameserverList('8.8.8.8:53').length === 0, 'a port is not part of an address here') | |
| 34 | ok(parseNameserverList('').length === 0 && parseNameserverList(null).length === 0, 'empty and null yield nothing') | |
| 35 | ok(parseNameserverList('...').length === 0, 'punctuation alone is not an address') | |
| 36 | ||
| 37 | const envWins = chooseNameservers({ envValue: '10.0.0.1', resolvConf: 'nameserver 127.0.0.53' }) | |
| 38 | ok(envWins.source === 'env' && JSON.stringify(envWins.servers) === '["10.0.0.1"]', 'an embedder that names resolvers is not second-guessed') | |
| 39 | const file = chooseNameservers({ envValue: '', resolvConf: 'nameserver 127.0.0.53' }) | |
| 40 | ok(file.source === 'resolv.conf' && file.servers.length === 1, 'no env => the file, exactly as before') | |
| 41 | const neither = chooseNameservers({ envValue: null, resolvConf: null }) | |
| 42 | ok(neither.source === 'none' && neither.servers.length === 0, 'neither => none, and the caller disables the DNS path') | |
| 43 | // A junk env value must not SHADOW a working resolv.conf — that would turn verification off on a server | |
| 44 | // because someone exported the variable wrong. | |
| 45 | ok(chooseNameservers({ envValue: 'dns.google', resolvConf: 'nameserver 1.1.1.1' }).source === 'resolv.conf', 'an unusable env value falls through to the file') | |
| 46 | ||
| 22 | 47 | // ── query encoding ─────────────────────────────────────────────────────────────────────────────── |
| 23 | 48 | const q = encodeQuery('_atproto.pfrazee.com', 0x1234) |
| 24 | 49 | ok((q[0] << 8 | q[1]) === 0x1234, 'query carries the id we asked for') |
| … | … | @@ -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 { 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 } from '../lib/render.js' | |
| 2 | import { 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 |
| … | … | @@ -917,4 +917,43 @@ function t_is_empty (s, m) { ok(s === '', m) } |
| 917 | 917 | not(timelinePage({ result: { posts: [] } }), 'class="stale"', 'and never when no freshness was supplied') |
| 918 | 918 | } |
| 919 | 919 | |
| 920 | // ── app updates (handset only) ─────────────────────────────────────────────────────────────────── | |
| 921 | // The controls exist because staging and applying are separate: an update discovered at boot N can only | |
| 922 | // run at boot N+1, and with a 6h poll and no restart button the phone can only become current by | |
| 923 | // accident. Measured 2026-08-24: a handset ran a bundle whose replacement had been staged for half an | |
| 924 | // hour with no user-reachable way to apply it. | |
| 925 | { | |
| 926 | const H = 'eaa75a4bd017a3e0ae030aaf0bb49eab655ebd6fa9fd82232e810e498e2e8647' | |
| 927 | const N = 'ffff5a4bd017a3e0ae030aaf0bb49eab655ebd6fa9fd82232e810e498e2e8647' | |
| 928 | ||
| 929 | // ⛔ The shell (an APK) and the worklet bundle (OTA) version independently, so a new bundle can find | |
| 930 | // itself inside an old shell with no message handler. Buttons that silently do nothing are worse than | |
| 931 | // no buttons — hence a capability claim, not a guess. | |
| 932 | ok(appUpdateSection(null) === '', 'no shell => no section at all (this is also every server)') | |
| 933 | ok(appUpdateSection({ ota: false, running: H }) === '', 'a shell that cannot service the buttons gets none') | |
| 934 | ||
| 935 | const staged = appUpdateSection({ ota: true, running: H, staged: N, checkedAt: '2026-08-24T21:41:31.000Z' }) | |
| 936 | has(staged, 'An update is staged', 'a genuinely newer bundle is announced') | |
| 937 | has(staged, 'data-ota="restart"', 'and the restart that applies it is right there') | |
| 938 | has(staged, 'data-ota="check"', 'checking is not gated on anything') | |
| 939 | ||
| 940 | // ⛔ THE BUG THIS PAGE MUST NOT REPEAT. The OTA log announced "staged … — active on next start" naming | |
| 941 | // the bundle already running, because currentTreeHash was never passed. Rendering that as a pending | |
| 942 | // update would put the same lie on a screen. | |
| 943 | const same = appUpdateSection({ ota: true, running: H, staged: H }) | |
| 944 | has(same, 'No update staged', 'staged === running is not an update') | |
| 945 | not(same, 'An update is staged', 'and is never announced as one') | |
| 946 | ||
| 947 | const apk = appUpdateSection({ ota: true, running: null, staged: null }) | |
| 948 | has(apk, 'the bundle shipped in the app', 'no OTA bundle yet reads as the APK’s own, not as unknown') | |
| 949 | not(appUpdateSection({ ota: true, running: H }), 'last checked', 'no check yet => no invented timestamp') | |
| 950 | ||
| 951 | // It reaches the real page, signed in AND signed out — a restart is what repairs a session this page | |
| 952 | // has forgotten, so gating it on being signed in would hide it exactly when it is needed. | |
| 953 | const shell = { ota: true, running: H, staged: N } | |
| 954 | has(settingsPage({ viewer: null, shell }), 'App updates', 'the section renders signed out') | |
| 955 | has(settingsPage({ viewer: { did: 'did:plc:alice' }, shell }), 'App updates', 'and signed in') | |
| 956 | not(settingsPage({ viewer: null }), 'App updates', 'and never without a shell — this is a phone-only control') | |
| 957 | } | |
| 958 | ||
| 920 | 959 | console.log(`\nRENDER OK: ${pass} assertions`) |