|
| 1 | +// WebFinger (RFC 7033, https://www.rfc-editor.org/rfc/rfc7033) as a #206 |
| 2 | +// loader plugin — the WebFinger half of JSS issue #164. |
| 3 | +// |
| 4 | +// plugins: [{ module: 'webfinger/plugin.js', prefix: '/webfinger', |
| 5 | +// config: { podsRoot: './data', |
| 6 | +// baseUrl: 'https://pod.example', |
| 7 | +// actorPathTemplate: '/<user>/actor' } }] |
| 8 | +// |
| 9 | +// Serves `GET /.well-known/webfinger?resource=acct:<user>@<host>` → a JRD |
| 10 | +// (JSON Resource Descriptor, RFC 7033 §4.4): |
| 11 | +// |
| 12 | +// { "subject": "acct:alice@pod.example", |
| 13 | +// "aliases": [ "<webid>", "<profile-page>" ], |
| 14 | +// "links": [ |
| 15 | +// { "rel": "http://webfinger.net/rel/profile-page", "href": "<profile>" }, |
| 16 | +// { "rel": "self", "type": "application/activity+json", "href": "<actor>" }, |
| 17 | +// { "rel": "http://openid.net/specs/connect/1.0/issuer", "href": "<idp>" } |
| 18 | +// ] } |
| 19 | +// |
| 20 | +// WebFinger is the acct: → resource resolver the whole fediverse (Mastodon, |
| 21 | +// Pleroma, any ActivityPub client) uses to turn a handle like |
| 22 | +// `@alice@pod.example` into that pod's actor URL — it is exactly what the |
| 23 | +// mastodon/ and bluesky/ shims need pointed at their pods. It is ALSO how |
| 24 | +// Solid-OIDC relying parties and remoteStorage clients discover a pod's |
| 25 | +// issuer (the `http://openid.net/specs/connect/1.0/issuer` link) — hence |
| 26 | +// #164 wants it extracted from `--activitypub` into an always-on plugin |
| 27 | +// that many protocols hang links off. |
| 28 | +// |
| 29 | +// The pod is resolved from the acct: local part by scanning config.podsRoot |
| 30 | +// for a matching pod dir (the same scan nip05/ does), or in single-user |
| 31 | +// layout the one pod at the root. Unknown users get a 404 (RFC 7033 §4.5). |
| 32 | +// |
| 33 | +// ------------------------------------------------------------- the path |
| 34 | +// |
| 35 | +// Like nip05/, the interesting part is the path. `/.well-known/webfinger` |
| 36 | +// is a fixed absolute path, and a plugin is mounted under ONE prefix. It |
| 37 | +// works today by the identical chain of luck nip05 documents: the loader |
| 38 | +// hands plugins the real (scoped) Fastify instance and does not confine |
| 39 | +// routes to the prefix, so an exact-path GET registers fine and beats |
| 40 | +// core's LDP `GET /*` wildcard on route specificity; and it is served |
| 41 | +// unauthenticated only because core's auth preHandler blanket-exempts |
| 42 | +// `/.well-known/*` as the spec-mandated public namespace — NOT because the |
| 43 | +// plugin api granted anything. All load-bearing accidents of core's current |
| 44 | +// routing (see README "Findings"), so the absolute registration is a |
| 45 | +// guarded attempt and the same document is also served at the |
| 46 | +// contract-safe `<prefix>/webfinger`. |
| 47 | + |
| 48 | +import fs from 'node:fs/promises'; |
| 49 | +import path from 'node:path'; |
| 50 | + |
| 51 | +/** acct: local part / pod dir grammar (also blocks path traversal). */ |
| 52 | +const LOCAL_PART = /^[a-zA-Z0-9._-]+$/; |
| 53 | + |
| 54 | +/** Collapse accidental `//` (from an empty <user> substitution) to `/`. */ |
| 55 | +const collapseSlashes = (p) => p.replace(/\/{2,}/g, '/'); |
| 56 | + |
| 57 | +/** Does `<dir>/profile/card.jsonld` exist? (a pod always has its WebID card) */ |
| 58 | +async function hasCard(dir) { |
| 59 | + try { |
| 60 | + await fs.access(path.join(dir, 'profile', 'card.jsonld')); |
| 61 | + return true; |
| 62 | + } catch { |
| 63 | + return false; |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +/** |
| 68 | + * Parse the `resource` query into a pod local part. |
| 69 | + * acct:alice@host → { user: 'alice' } |
| 70 | + * acct:host → { user: 'host' } (no @ — bare acct) |
| 71 | + * https://host/alice/... → { user: 'alice' } (path-mode WebID URL) |
| 72 | + * https://host/profile/... → { user: null } (single-user WebID URL) |
| 73 | + * Returns null when the resource is syntactically unusable. |
| 74 | + */ |
| 75 | +export function parseResource(resource) { |
| 76 | + if (typeof resource !== 'string' || resource === '') return null; |
| 77 | + if (resource.startsWith('acct:')) { |
| 78 | + const acct = resource.slice('acct:'.length); |
| 79 | + const at = acct.lastIndexOf('@'); |
| 80 | + const user = at >= 0 ? acct.slice(0, at) : acct; |
| 81 | + return user ? { user } : null; |
| 82 | + } |
| 83 | + // Otherwise treat it as a URI whose path locates the pod (the WebID form). |
| 84 | + let u; |
| 85 | + try { |
| 86 | + u = new URL(resource); |
| 87 | + } catch { |
| 88 | + return null; |
| 89 | + } |
| 90 | + const segs = u.pathname.split('/').filter(Boolean); |
| 91 | + // Single-user layout: WebID lives at `<origin>/profile/card`. |
| 92 | + if (segs.length === 0 || segs[0] === 'profile') return { user: null }; |
| 93 | + return { user: segs[0] }; |
| 94 | +} |
| 95 | + |
| 96 | +export async function activate(api) { |
| 97 | + const prefix = api.prefix || '/webfinger'; |
| 98 | + const podsRoot = api.config.podsRoot ?? null; |
| 99 | + const baseUrl = (api.config.baseUrl || '').replace(/\/$/, ''); |
| 100 | + // `null`/`''` disables the `self` (ActivityPub actor) link — "IF |
| 101 | + // applicable" from the issue: a deployment without AP actors omits it. |
| 102 | + const actorPathTemplate = 'actorPathTemplate' in api.config |
| 103 | + ? api.config.actorPathTemplate |
| 104 | + : '/<user>/actor'; |
| 105 | + |
| 106 | + if (!baseUrl) { |
| 107 | + // A JRD is nothing but absolute URLs of this server's own origin, and |
| 108 | + // the plugin api exposes no server origin (same finding as mastodon/, |
| 109 | + // notifications/, webdav/). Without it there is nothing truthful to |
| 110 | + // serve, so unlike nip05 (whose document carries no self-origin URLs) |
| 111 | + // this is a hard boot failure. |
| 112 | + throw new Error( |
| 113 | + 'webfinger plugin requires config.baseUrl — the plugin api exposes no ' |
| 114 | + + 'server origin, and every URL in a JRD (WebID, profile page, actor, ' |
| 115 | + + 'issuer) is absolute against it (same finding as mastodon/notifications).', |
| 116 | + ); |
| 117 | + } |
| 118 | + if (!podsRoot) { |
| 119 | + // Without a data root the plugin cannot resolve any user, so every |
| 120 | + // query 404s. Still activate (a purely additive discovery endpoint |
| 121 | + // shouldn't fail the whole boot), but say so loudly — see README |
| 122 | + // finding on podsRoot repetition. |
| 123 | + api.log.warn('webfinger: no config.podsRoot — every resource will 404 ' |
| 124 | + + '(the plugin api cannot learn the data root itself)'); |
| 125 | + } |
| 126 | + |
| 127 | + /** |
| 128 | + * Confirm the pod named by `user` exists (has a WebID card), scanning |
| 129 | + * fresh per request so pods provisioned after boot resolve immediately. |
| 130 | + * `user === null` means single-user layout (card at the podsRoot itself). |
| 131 | + */ |
| 132 | + async function podExists(user) { |
| 133 | + if (!podsRoot) return false; |
| 134 | + if (user === null) return hasCard(podsRoot); |
| 135 | + if (!LOCAL_PART.test(user)) return false; // illegal / traversal attempt |
| 136 | + if (user.startsWith('.')) return false; // .idp, .plugins, .well-known… |
| 137 | + return hasCard(path.join(podsRoot, user)); |
| 138 | + } |
| 139 | + |
| 140 | + /** Build the JRD for a resolved pod. `subject` echoes the query verbatim. */ |
| 141 | + function buildJrd(user, subject) { |
| 142 | + const podPath = user ? `/${user}/` : '/'; |
| 143 | + const webid = `${baseUrl}${podPath}profile/card#me`; |
| 144 | + const profilePage = `${baseUrl}${podPath}profile/card`; |
| 145 | + const links = [ |
| 146 | + { |
| 147 | + rel: 'http://webfinger.net/rel/profile-page', |
| 148 | + type: 'text/html', |
| 149 | + href: profilePage, |
| 150 | + }, |
| 151 | + ]; |
| 152 | + if (actorPathTemplate) { |
| 153 | + const actorPath = collapseSlashes(actorPathTemplate.replaceAll('<user>', user ?? '')); |
| 154 | + links.push({ |
| 155 | + rel: 'self', |
| 156 | + type: 'application/activity+json', |
| 157 | + href: `${baseUrl}${actorPath}`, |
| 158 | + }); |
| 159 | + } |
| 160 | + // The IdP issuer link makes Solid-OIDC / remoteStorage discovery work |
| 161 | + // off the same document (the #164 point: WebFinger serves many protocols). |
| 162 | + links.push({ |
| 163 | + rel: 'http://openid.net/specs/connect/1.0/issuer', |
| 164 | + href: baseUrl, |
| 165 | + }); |
| 166 | + return { subject, aliases: [webid, profilePage], links }; |
| 167 | + } |
| 168 | + |
| 169 | + async function handler(request, reply) { |
| 170 | + // Fediverse clients (browsers, bots) carry no credentials: CORS must be |
| 171 | + // wide open, which RFC 7033 §5 calls out explicitly. |
| 172 | + reply.header('access-control-allow-origin', '*'); |
| 173 | + const resource = request.query?.resource; |
| 174 | + if (typeof resource !== 'string' || resource === '') { |
| 175 | + // RFC 7033 §4.2: `resource` is REQUIRED. |
| 176 | + return reply.code(400) |
| 177 | + .header('content-type', 'application/json; charset=utf-8') |
| 178 | + .send({ error: 'the "resource" query parameter is required' }); |
| 179 | + } |
| 180 | + const parsed = parseResource(resource); |
| 181 | + if (!parsed || !(await podExists(parsed.user))) { |
| 182 | + // RFC 7033 §4.5: no such resource → 404. |
| 183 | + return reply.code(404) |
| 184 | + .header('content-type', 'application/json; charset=utf-8') |
| 185 | + .send({ error: 'no such resource' }); |
| 186 | + } |
| 187 | + return reply |
| 188 | + .header('content-type', 'application/jrd+json; charset=utf-8') |
| 189 | + .send(buildJrd(parsed.user, resource)); |
| 190 | + } |
| 191 | + |
| 192 | + // Contract-safe mount: under the plugin's own prefix (WAC-exempt via the |
| 193 | + // loader's appPaths push — the only exemption the plugin api grants). |
| 194 | + api.fastify.get(`${prefix}/webfinger`, handler); |
| 195 | + |
| 196 | + // Attempt the real WebFinger location. Works today for the same reasons |
| 197 | + // nip05/ documents (loader doesn't confine routes to the prefix; core's |
| 198 | + // GET there is only the LDP wildcard; core blanket-exempts /.well-known/* |
| 199 | + // from auth) — but nothing in the plugin CONTRACT promises any of it, so |
| 200 | + // treat a conflict (e.g. core someday claiming the GET route) as degraded, |
| 201 | + // not fatal: the prefix mount still serves. |
| 202 | + let wellKnown = false; |
| 203 | + try { |
| 204 | + api.fastify.get('/.well-known/webfinger', handler); |
| 205 | + wellKnown = true; |
| 206 | + } catch (err) { |
| 207 | + api.log.warn(`webfinger: could not claim /.well-known/webfinger (${err.message}); ` |
| 208 | + + `serving under ${prefix}/webfinger only`); |
| 209 | + } |
| 210 | + |
| 211 | + api.log.info(`webfinger: serving ${prefix}/webfinger` |
| 212 | + + (wellKnown ? ' and /.well-known/webfinger' : '') |
| 213 | + + (podsRoot ? ` from ${podsRoot}` : ' (no podsRoot: every resource 404s)')); |
| 214 | +} |
0 commit comments