fedipod-server 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -8
- package/config/server.json +11 -0
- package/dist/claims.js +16 -5
- package/dist/components/context.jsonld +6 -0
- package/dist/directory.d.ts +11 -0
- package/dist/directory.js +19 -0
- package/dist/handler.d.ts +18 -2
- package/dist/handler.js +63 -27
- package/dist/handler.jsonld +27 -0
- package/lib/acctfeed.mjs +257 -0
- package/lib/admin.mjs +187 -7
- package/lib/bskygroup.mjs +1 -1
- package/lib/c2s.mjs +104 -4
- package/lib/embed.mjs +6 -1
- package/lib/fediacct.mjs +257 -0
- package/lib/front-core.mjs +74 -4
- package/lib/intake.mjs +21 -2
- package/lib/links.mjs +35 -0
- package/lib/mastoapi.mjs +298 -13
- package/lib/publisher.mjs +12 -1
- package/lib/remote.mjs +119 -3
- package/lib/setup.mjs +56 -0
- package/lib/storage.mjs +8 -2
- package/lib/store.mjs +26 -1
- package/lib/wire.mjs +20 -5
- package/package.json +1 -1
- package/phanpy/dist/assets/main-BdqNbG-a.js +1 -1
- package/phanpy/dist/compose/index.html +1 -1
- package/phanpy/dist/index.html +1 -1
- package/phanpy/dist/sw.js +2 -25
- package/phanpy/dist/sw.js.map +1 -0
- package/run-agent.mjs +35 -0
- package/web/admin/admin.js +81 -22
- package/web/admin/index.html +37 -8
- package/web/admin/setup/index.html +20 -4
- package/web/admin/setup/setup.js +50 -10
- package/web/front/admin.html +133 -0
- package/web/front/new-account.html +2 -2
- package/web/front/run.html +20 -16
- package/web/front/solid-oidc-client.js +6 -0
- package/web/front/solid-client-authn.bundle.js +0 -2
package/lib/mastoapi.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { sanitizeHtml, followsNeedApproval, publicHandle } from './wire.mjs';
|
|
|
16
16
|
import { authorOf } from './intake.mjs';
|
|
17
17
|
import { profileUrl, postUrl } from './bskyfeed.mjs';
|
|
18
18
|
import { Push } from './webpush.mjs';
|
|
19
|
+
import { safeFetch, readCapped } from './safefetch.mjs';
|
|
19
20
|
|
|
20
21
|
// What an attachment is allowed to BE. Anything else is stored as bytes, which
|
|
21
22
|
// a browser downloads rather than runs.
|
|
@@ -62,6 +63,8 @@ const AUTHZ_WINDOW_MS = 60_000;
|
|
|
62
63
|
const AUTHZ_MAX_ATTEMPTS = 5;
|
|
63
64
|
const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
|
|
64
65
|
const MAX_APPS = 200; // registered third-party clients, capped
|
|
66
|
+
const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
|
|
67
|
+
const CLIENT_DOC_MAX = 64 * 1024; // it names a client; it is not a payload
|
|
65
68
|
|
|
66
69
|
export class MastoApi {
|
|
67
70
|
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false }) {
|
|
@@ -127,7 +130,91 @@ export class MastoApi {
|
|
|
127
130
|
// said, and only that client, presenting its secret, can exchange it for a
|
|
128
131
|
// bearer. A redirect back to this agent's own origin keeps the local flow.
|
|
129
132
|
apps() { return this.store.read('oauth-apps.json', []); }
|
|
133
|
+
/**
|
|
134
|
+
* What a client needs to know before it can sign in, at the address RFC 8414
|
|
135
|
+
* puts it. The actor carries the same two endpoints; a client that looks
|
|
136
|
+
* here first finds everything rather than the minimum.
|
|
137
|
+
*
|
|
138
|
+
* `none` among the authentication methods is what says a client keeping no
|
|
139
|
+
* secret is welcome, which is the whole of what a browser app needs to hear.
|
|
140
|
+
*/
|
|
141
|
+
authorizationServerMetadata(origin) {
|
|
142
|
+
const at = (p) => `${origin.replace(/\/$/u, '')}${p}`;
|
|
143
|
+
return {
|
|
144
|
+
issuer: origin.replace(/\/$/u, ''),
|
|
145
|
+
authorization_endpoint: at('/oauth/authorize'),
|
|
146
|
+
token_endpoint: at('/oauth/token'),
|
|
147
|
+
revocation_endpoint: at('/oauth/revoke'),
|
|
148
|
+
registration_endpoint: at('/api/v1/apps'),
|
|
149
|
+
response_types_supported: [ 'code' ],
|
|
150
|
+
grant_types_supported: [ 'authorization_code' ],
|
|
151
|
+
code_challenge_methods_supported: [ 'S256', 'plain' ],
|
|
152
|
+
token_endpoint_auth_methods_supported: [ 'client_secret_post', 'none' ],
|
|
153
|
+
scopes_supported: [ 'read', 'write', 'follow', 'push' ],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
130
157
|
findApp(clientId) { return clientId ? this.apps().find(a => a.clientId === clientId) || null : null; }
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A client that publishes its own metadata document is named by that
|
|
161
|
+
* document's URL and registers nothing here: the document says who it is
|
|
162
|
+
* and where it may be sent back to. Such a client keeps no secret, so it
|
|
163
|
+
* always proves itself with a challenge instead.
|
|
164
|
+
*
|
|
165
|
+
* The fetch is the guarded one — a client id is a URL a stranger chose, and
|
|
166
|
+
* an unguarded fetch of it would ask this machine to reach wherever they
|
|
167
|
+
* pointed.
|
|
168
|
+
*/
|
|
169
|
+
async resolveClientDocument(clientId) {
|
|
170
|
+
if (!/^https:\/\//iu.test(String(clientId || ''))) return null; // cleartext is refused
|
|
171
|
+
this.clientDocs = this.clientDocs || new Map();
|
|
172
|
+
const seen = this.clientDocs.get(clientId);
|
|
173
|
+
if (seen && Date.now() - seen.at < CLIENT_DOC_TTL_MS) return seen.client;
|
|
174
|
+
let doc;
|
|
175
|
+
try {
|
|
176
|
+
const res = await safeFetch(clientId, { headers: { accept: 'application/json' } });
|
|
177
|
+
if (res.status >= 400) { this.log(`client document ${clientId} → ${res.status}`); return null; }
|
|
178
|
+
doc = JSON.parse(await readCapped(res, CLIENT_DOC_MAX));
|
|
179
|
+
} catch (e) {
|
|
180
|
+
this.log(`client document ${clientId} could not be read: ${e.message}`);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
// It must claim to be itself: a document naming some other id would let
|
|
184
|
+
// one client borrow another's name.
|
|
185
|
+
if (doc?.client_id !== clientId) {
|
|
186
|
+
this.log(`client document ${clientId} names ${doc?.client_id ?? 'nothing'} — refused`);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
const redirectUris = [].concat(doc.redirect_uris || []).filter((u) => typeof u === 'string');
|
|
190
|
+
if (!redirectUris.length) { this.log(`client document ${clientId} names no redirect — refused`); return null; }
|
|
191
|
+
const client = {
|
|
192
|
+
clientId, redirectUris,
|
|
193
|
+
name: String(doc.client_name || clientId).slice(0, 200),
|
|
194
|
+
scopes: 'read write follow',
|
|
195
|
+
};
|
|
196
|
+
this.clientDocs.set(clientId, { at: Date.now(), client });
|
|
197
|
+
return client;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Whether a redirect the client asked for is one it published.
|
|
202
|
+
*
|
|
203
|
+
* A native client listens on whatever port the machine gave it, so it can
|
|
204
|
+
* only publish the loopback address without one (RFC 8252). The port is
|
|
205
|
+
* therefore not part of the match there, and nowhere else.
|
|
206
|
+
*/
|
|
207
|
+
static redirectMatches(published, asked) {
|
|
208
|
+
if (published === asked) return true;
|
|
209
|
+
try {
|
|
210
|
+
const a = new URL(published);
|
|
211
|
+
const b = new URL(asked);
|
|
212
|
+
const loopback = (h) => h === '127.0.0.1' || h === '[::1]' || h === 'localhost';
|
|
213
|
+
if (!loopback(a.hostname) || a.hostname !== b.hostname) return false;
|
|
214
|
+
return a.protocol === b.protocol
|
|
215
|
+
&& a.pathname.replace(/\/$/u, '') === b.pathname.replace(/\/$/u, '');
|
|
216
|
+
} catch { return false; }
|
|
217
|
+
}
|
|
131
218
|
registerApp({ name, website, redirectUris, scopes }) {
|
|
132
219
|
const app = {
|
|
133
220
|
clientId: crypto.randomBytes(16).toString('hex'),
|
|
@@ -143,13 +230,39 @@ export class MastoApi {
|
|
|
143
230
|
// A short-lived, single-use authorization code for a registered client, kept
|
|
144
231
|
// apart from masto-tokens.json so the code is NOT a bearer until it is
|
|
145
232
|
// exchanged with the client secret.
|
|
146
|
-
mintCode({ clientId, redirectUri, scope }) {
|
|
233
|
+
mintCode({ clientId, redirectUri, scope, challenge = null, challengeMethod = null }) {
|
|
147
234
|
const code = crypto.randomBytes(24).toString('hex');
|
|
148
235
|
const now = Date.now();
|
|
149
236
|
const kept = this.store.read('oauth-codes.json', []).filter(c => now - c.createdAt < CODE_TTL_MS);
|
|
150
|
-
this.store.write('oauth-codes.json', [...kept, {
|
|
237
|
+
this.store.write('oauth-codes.json', [ ...kept, {
|
|
238
|
+
code, clientId, redirectUri, scope, createdAt: now,
|
|
239
|
+
// What the client promised to prove when it comes back for the token.
|
|
240
|
+
// A client that cannot keep a secret — anything running in a browser —
|
|
241
|
+
// has this instead, and it is the only thing standing between a stolen
|
|
242
|
+
// code and a token.
|
|
243
|
+
...(challenge ? { challenge, challengeMethod: challengeMethod || 'plain' } : {}),
|
|
244
|
+
} ].slice(-50));
|
|
151
245
|
return code;
|
|
152
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Whether this verifier is the one the challenge was made from (RFC 7636).
|
|
249
|
+
* Length is checked because a short verifier is guessable, which is the
|
|
250
|
+
* whole thing this is here to prevent.
|
|
251
|
+
*/
|
|
252
|
+
static provesCode(rec, verifier) {
|
|
253
|
+
const v = String(verifier || '');
|
|
254
|
+
if (v.length < 43 || v.length > 128) return false;
|
|
255
|
+
if ((rec.challengeMethod || 'plain') === 'S256') {
|
|
256
|
+
const made = crypto.createHash('sha256').update(v).digest('base64url');
|
|
257
|
+
const given = Buffer.from(made);
|
|
258
|
+
const known = Buffer.from(String(rec.challenge));
|
|
259
|
+
return given.length === known.length && crypto.timingSafeEqual(given, known);
|
|
260
|
+
}
|
|
261
|
+
const given = Buffer.from(v);
|
|
262
|
+
const known = Buffer.from(String(rec.challenge));
|
|
263
|
+
return given.length === known.length && crypto.timingSafeEqual(given, known);
|
|
264
|
+
}
|
|
265
|
+
|
|
153
266
|
consumeCode(code) {
|
|
154
267
|
const now = Date.now();
|
|
155
268
|
const all = this.store.read('oauth-codes.json', []);
|
|
@@ -327,6 +440,76 @@ export class MastoApi {
|
|
|
327
440
|
} catch (e) { return send(422, { error: e.message }); }
|
|
328
441
|
}
|
|
329
442
|
|
|
443
|
+
// Which account acts: the one that saw the post, and the first of them when
|
|
444
|
+
// several did. Undo inverts that — a like fans IN to one account, an unlike
|
|
445
|
+
// fans OUT to every account holding one, because a stray like left behind
|
|
446
|
+
// after the owner asked for it to go is the worse failure.
|
|
447
|
+
async acctAction(send, s, verb) {
|
|
448
|
+
const accounts = this.agent.fediaccts;
|
|
449
|
+
const held = s.sourceAccts || [];
|
|
450
|
+
if (!accounts || !held.length) {
|
|
451
|
+
return send(422, { error: 'this post came from a connected account, and none is connected now' });
|
|
452
|
+
}
|
|
453
|
+
const flag = verb.endsWith('favourite') ? 'favourited' : 'reblogged';
|
|
454
|
+
const undo = verb.startsWith('un');
|
|
455
|
+
const targets = (undo ? held.filter(v => v[flag]) : [held[0]]).filter(v => v?.remoteId);
|
|
456
|
+
if (!targets.length) return send(200, this.status(s));
|
|
457
|
+
try {
|
|
458
|
+
const acted = new Set();
|
|
459
|
+
for (const v of targets) {
|
|
460
|
+
await accounts.api(v.acct,
|
|
461
|
+
`/api/v1/statuses/${encodeURIComponent(v.remoteId)}/${verb}`, { method: 'POST' });
|
|
462
|
+
acted.add(v.acct);
|
|
463
|
+
}
|
|
464
|
+
const next = held.map(v => (acted.has(v.acct) ? { ...v, [flag]: !undo } : v));
|
|
465
|
+
return send(200, this.status(this.store.updateStatus(s.noteId, { sourceAccts: next }) || s));
|
|
466
|
+
} catch (e) { return send(e.status === 401 ? 401 : 422, { error: e.message }); }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// The reply exists only on that account's server, so the row added here is
|
|
470
|
+
// its one local copy — the same shape bskyReply uses for the same reason.
|
|
471
|
+
async acctReply(send, body, parent, visibility) {
|
|
472
|
+
const accounts = this.agent.fediaccts;
|
|
473
|
+
const held = (parent.sourceAccts || [])[0];
|
|
474
|
+
if (!accounts || !held?.remoteId) {
|
|
475
|
+
return send(422, { error: 'this post came from a connected account, and none is connected now' });
|
|
476
|
+
}
|
|
477
|
+
if (visibility !== 'public' && visibility !== 'unlisted') {
|
|
478
|
+
return send(422, { error: 'a reply from a connected account is public — pick public or unlisted' });
|
|
479
|
+
}
|
|
480
|
+
if (body.scheduled_at) return send(422, { error: 'a reply from a connected account cannot be scheduled' });
|
|
481
|
+
if ([].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean).length) {
|
|
482
|
+
return send(422, { error: 'images on a reply from a connected account are not supported' });
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
const out = await accounts.apiJson(held.acct, '/api/v1/statuses', {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: { 'content-type': 'application/json' },
|
|
488
|
+
body: JSON.stringify({
|
|
489
|
+
status: body.status, in_reply_to_id: held.remoteId, visibility,
|
|
490
|
+
...(body.spoiler_text ? { spoiler_text: String(body.spoiler_text) } : {}),
|
|
491
|
+
}),
|
|
492
|
+
});
|
|
493
|
+
if (!out?.uri) return send(502, { error: 'that server accepted the reply but did not say where it is' });
|
|
494
|
+
const rec = accounts.read(held.acct);
|
|
495
|
+
const actor = out.account?.uri || out.account?.url || rec?.actorUrl;
|
|
496
|
+
if (actor && !this.store.getActors()[actor]) {
|
|
497
|
+
this.store.cacheActor(actor, {
|
|
498
|
+
name: out.account?.display_name || rec?.name,
|
|
499
|
+
preferredUsername: out.account?.username || rec?.acct, type: 'Person',
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
this.store.addStatus({
|
|
503
|
+
noteId: out.uri, actor, inReplyTo: parent.noteId,
|
|
504
|
+
content: sanitizeHtml(out.content || ''),
|
|
505
|
+
published: out.created_at || new Date().toISOString(), kind: 'acct',
|
|
506
|
+
sourceAccts: [{ acct: held.acct, remoteId: String(out.id) }],
|
|
507
|
+
...(out.url && out.url !== out.uri ? { link: out.url } : {}),
|
|
508
|
+
});
|
|
509
|
+
return send(200, this.status(this.store.getStatuses().find(x => x.noteId === out.uri)));
|
|
510
|
+
} catch (e) { return send(422, { error: e.message }); }
|
|
511
|
+
}
|
|
512
|
+
|
|
330
513
|
status(s, { all } = {}) {
|
|
331
514
|
const replies = (all || this.store.getStatuses()).filter(x => x.inReplyTo === s.noteId).length;
|
|
332
515
|
return {
|
|
@@ -339,7 +522,11 @@ export class MastoApi {
|
|
|
339
522
|
edited_at: s.editedAt || null,
|
|
340
523
|
uri: s.noteId, url: s.link || s.noteId,
|
|
341
524
|
replies_count: replies, reblogs_count: 0, favourites_count: 0,
|
|
342
|
-
|
|
525
|
+
// True when ANY of the owner's accounts holds it. The flag is really
|
|
526
|
+
// what the next tap will do: an empty star on a post one account has
|
|
527
|
+
// already liked invites a second outward like from a second identity.
|
|
528
|
+
favourited: !!s.favourited || (s.sourceAccts || []).some(v => v.favourited),
|
|
529
|
+
reblogged: !!s.reblogged || (s.sourceAccts || []).some(v => v.reblogged),
|
|
343
530
|
muted: false, bookmarked: !!s.bookmarked, pinned: !!s.pinned,
|
|
344
531
|
content: s.content || '',
|
|
345
532
|
reblog: null, application: null,
|
|
@@ -546,19 +733,35 @@ export class MastoApi {
|
|
|
546
733
|
if (req.method === 'POST') { body = await readBody(req); params = new URLSearchParams(body); }
|
|
547
734
|
const redirect = params.get('redirect_uri') || '';
|
|
548
735
|
const app = this.findApp(params.get('client_id') || '');
|
|
736
|
+
// A client that published its own metadata document needs no
|
|
737
|
+
// registration here: the document is its name and says where it may be
|
|
738
|
+
// sent back to.
|
|
739
|
+
const doc = app ? null : await this.resolveClientDocument(params.get('client_id') || '');
|
|
549
740
|
// A REGISTERED client is always the third-party flow — its code is
|
|
550
741
|
// bound to it and exchanged with its secret — even when its redirect
|
|
551
742
|
// points back at this very agent (a web client served from our own
|
|
552
743
|
// origin registers itself exactly like a phone app does). The local
|
|
553
744
|
// code-is-the-token flow is only for the built-in client, which never
|
|
554
745
|
// registers.
|
|
555
|
-
const external = !!app;
|
|
556
|
-
const client = { name: app?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
557
|
-
if (
|
|
746
|
+
const external = !!app || !!doc;
|
|
747
|
+
const client = { name: app?.name || doc?.name || null, redirect, scope: params.get('scope') || 'read' };
|
|
748
|
+
if (app) {
|
|
558
749
|
if (!app.redirectUris.includes(redirect)) {
|
|
559
750
|
this.log(`authorize refused: redirect_uri "${redirect}" not registered for ${app.clientId}`);
|
|
560
751
|
return send(400, { error: 'redirect_uri was not registered by this client' });
|
|
561
752
|
}
|
|
753
|
+
} else if (doc) {
|
|
754
|
+
if (!doc.redirectUris.some((u) => MastoApi.redirectMatches(u, redirect))) {
|
|
755
|
+
this.log(`authorize refused: redirect_uri "${redirect}" is not one ${doc.clientId} published`);
|
|
756
|
+
return send(400, { error: 'redirect_uri is not one this client published' });
|
|
757
|
+
}
|
|
758
|
+
// It keeps no secret, so the challenge is the only thing that will
|
|
759
|
+
// stand between its code and a token. Refuse now rather than mint a
|
|
760
|
+
// code nothing can prove.
|
|
761
|
+
if (!params.get('code_challenge')) {
|
|
762
|
+
this.log(`authorize refused: ${doc.clientId} keeps no secret and offered no challenge`);
|
|
763
|
+
return send(400, { error: 'a client identified by its own document must send a code_challenge' });
|
|
764
|
+
}
|
|
562
765
|
} else if (!this.redirectAllowed(redirect)) {
|
|
563
766
|
this.log(`authorize refused: redirect_uri "${redirect}" is not this agent`);
|
|
564
767
|
return send(400, { error: 'redirect_uri must be an address of this agent' });
|
|
@@ -566,7 +769,11 @@ export class MastoApi {
|
|
|
566
769
|
if (req.method === 'POST') {
|
|
567
770
|
if (this.rateLimited()) {
|
|
568
771
|
this.log('authorize rate limited');
|
|
569
|
-
|
|
772
|
+
// 429, not 401: a client that reads this as a wrong password will
|
|
773
|
+
// ask the person to type it again, which is the one thing that
|
|
774
|
+
// cannot help. Retry-After says how long the wait actually is.
|
|
775
|
+
return sendLoginForm(res, params, 'too many attempts — wait a minute', client,
|
|
776
|
+
429, { 'retry-after': String(Math.ceil(AUTHZ_WINDOW_MS / 1000)) });
|
|
570
777
|
}
|
|
571
778
|
if (!pw || !checkPassword(pw, body.password || '')) {
|
|
572
779
|
return sendLoginForm(res, params, 'wrong password — try again', client);
|
|
@@ -596,7 +803,9 @@ export class MastoApi {
|
|
|
596
803
|
}
|
|
597
804
|
// External clients get a bound code; the local flow keeps code==token.
|
|
598
805
|
const code = external
|
|
599
|
-
? this.mintCode({ clientId: app.clientId, redirectUri: redirect, scope: client.scope
|
|
806
|
+
? this.mintCode({ clientId: (app || doc).clientId, redirectUri: redirect, scope: client.scope,
|
|
807
|
+
challenge: params.get('code_challenge') || null,
|
|
808
|
+
challengeMethod: params.get('code_challenge_method') || null })
|
|
600
809
|
: this.mintToken();
|
|
601
810
|
if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
|
|
602
811
|
const target = new URL(redirect);
|
|
@@ -611,6 +820,44 @@ export class MastoApi {
|
|
|
611
820
|
// A registered third-party client exchanges its bound code, proving its
|
|
612
821
|
// secret, for a real bearer — the code alone is not a token.
|
|
613
822
|
const app = this.findApp(body.client_id || '');
|
|
823
|
+
// A client named by its own document keeps no secret at all, so the
|
|
824
|
+
// challenge is the whole of its proof. The code carries the document's
|
|
825
|
+
// URL as the client it was bound to.
|
|
826
|
+
if (!app && body.code_verifier && /^https:\/\//iu.test(String(body.client_id || ''))) {
|
|
827
|
+
const rec = this.consumeCode(body.code || '');
|
|
828
|
+
if (!rec || rec.clientId !== body.client_id
|
|
829
|
+
|| (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
830
|
+
this.log('token refused: code is not a live authorization for that client document');
|
|
831
|
+
return send(400, { error: 'invalid_grant' });
|
|
832
|
+
}
|
|
833
|
+
if (!rec.challenge || !MastoApi.provesCode(rec, body.code_verifier)) {
|
|
834
|
+
this.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
835
|
+
return send(400, { error: 'invalid_grant' });
|
|
836
|
+
}
|
|
837
|
+
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
838
|
+
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
839
|
+
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
840
|
+
}
|
|
841
|
+
// A client that runs in a browser cannot keep a secret, so it proves it
|
|
842
|
+
// is the same caller that asked instead: it sends the verifier for the
|
|
843
|
+
// challenge it presented at authorize (RFC 7636). Sending a verifier is
|
|
844
|
+
// what says which of the two flows this is.
|
|
845
|
+
if (app && body.code_verifier) {
|
|
846
|
+
const rec = this.consumeCode(body.code || '');
|
|
847
|
+
if (!rec || rec.clientId !== app.clientId || (body.redirect_uri && rec.redirectUri !== body.redirect_uri)) {
|
|
848
|
+
this.log('token refused: code is not a live authorization for this client');
|
|
849
|
+
return send(400, { error: 'invalid_grant' });
|
|
850
|
+
}
|
|
851
|
+
// A code minted without a challenge cannot be redeemed with one: that
|
|
852
|
+
// would let anyone holding a stolen code invent the proof for it.
|
|
853
|
+
if (!rec.challenge || !MastoApi.provesCode(rec, body.code_verifier)) {
|
|
854
|
+
this.log('token refused: the verifier does not answer the challenge this code was made with');
|
|
855
|
+
return send(400, { error: 'invalid_grant' });
|
|
856
|
+
}
|
|
857
|
+
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
858
|
+
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
859
|
+
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
860
|
+
}
|
|
614
861
|
if (app && body.client_secret) {
|
|
615
862
|
const given = Buffer.from(String(body.client_secret));
|
|
616
863
|
const known = Buffer.from(app.clientSecret);
|
|
@@ -621,8 +868,18 @@ export class MastoApi {
|
|
|
621
868
|
this.log('token refused: code is not a live authorization for this client');
|
|
622
869
|
return send(400, { error: 'invalid_grant' });
|
|
623
870
|
}
|
|
871
|
+
// A challenge, once made, is not optional: without this a client could
|
|
872
|
+
// present one and then skip past it with the secret alone.
|
|
873
|
+
if (rec.challenge && !MastoApi.provesCode(rec, body.code_verifier)) {
|
|
874
|
+
this.log('token refused: this code was made with a challenge and the verifier does not answer it');
|
|
875
|
+
return send(400, { error: 'invalid_grant' });
|
|
876
|
+
}
|
|
624
877
|
return send(200, { access_token: this.mintToken(), token_type: 'Bearer',
|
|
625
|
-
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000)
|
|
878
|
+
scope: rec.scope || 'read', created_at: Math.floor(Date.now() / 1000),
|
|
879
|
+
// Which actor the token acts for. A Mastodon client ignores it; an
|
|
880
|
+
// ActivityPub API client needs it, and asking for it separately
|
|
881
|
+
// would mean a second round trip before it knows who it is.
|
|
882
|
+
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
626
883
|
}
|
|
627
884
|
// Local flow: the code IS the token, minted by /oauth/authorize after the
|
|
628
885
|
// password gate. Minting one here for an unrecognised code handed a
|
|
@@ -633,7 +890,9 @@ export class MastoApi {
|
|
|
633
890
|
this.log('token refused: code is not a live authorization');
|
|
634
891
|
return send(400, { error: 'invalid_grant' });
|
|
635
892
|
}
|
|
636
|
-
return send(200, { access_token: body.code, token_type: 'Bearer',
|
|
893
|
+
return send(200, { access_token: body.code, token_type: 'Bearer',
|
|
894
|
+
scope: body.scope || 'read write follow push', created_at: Math.floor(Date.now() / 1000),
|
|
895
|
+
...(this.urls?.actor ? { activitypub_actor_id: this.urls.actor } : {}) });
|
|
637
896
|
}
|
|
638
897
|
if (pathname === '/oauth/revoke' && req.method === 'POST') {
|
|
639
898
|
// It used to answer 200 and keep the token, so logging out of a client
|
|
@@ -807,6 +1066,10 @@ export class MastoApi {
|
|
|
807
1066
|
// — the parent is not an AP object, so there is no AP note to make.
|
|
808
1067
|
const bskyParent = inReplyTo && this.store.getStatuses().find(x => x.noteId === inReplyTo && x.kind === 'bsky');
|
|
809
1068
|
if (bskyParent) return this.bskyReply(send, body, bskyParent, visibility);
|
|
1069
|
+
// A reply to a post a connected account brought us goes out from that
|
|
1070
|
+
// account, where the conversation already is.
|
|
1071
|
+
const acctParent = inReplyTo && this.store.getStatuses().find(x => x.noteId === inReplyTo && x.kind === 'acct');
|
|
1072
|
+
if (acctParent) return this.acctReply(send, body, acctParent, visibility);
|
|
810
1073
|
// Private and direct posts live in an owner-only pod container — and
|
|
811
1074
|
// only on a pod that provably enforces it.
|
|
812
1075
|
if (visibility === 'private' || visibility === 'direct') {
|
|
@@ -888,8 +1151,24 @@ export class MastoApi {
|
|
|
888
1151
|
const noteUrl = this.store.urlFor(mStatus[1]);
|
|
889
1152
|
const s = noteUrl && this.store.getStatuses().find(x => x.noteId === noteUrl);
|
|
890
1153
|
if (!s) return send(404, { error: 'Record not found' });
|
|
891
|
-
|
|
1154
|
+
// A post the owner wrote on a connected account is theirs to delete too.
|
|
1155
|
+
// It is deleted where it lives — an AP Delete of ours would address an
|
|
1156
|
+
// object that was never ours to speak for.
|
|
1157
|
+
const onAcct = s.actor !== this.urls.actor
|
|
1158
|
+
? (this.agent.fediaccts?.list() || []).find(r => r.actorUrl && r.actorUrl === s.actor)
|
|
1159
|
+
: null;
|
|
1160
|
+
if (s.actor !== this.urls.actor && !onAcct) return send(403, { error: 'not your status' });
|
|
892
1161
|
const rendered = this.status(s);
|
|
1162
|
+
if (onAcct) {
|
|
1163
|
+
const held = (s.sourceAccts || []).find(v => v.acct === onAcct.id);
|
|
1164
|
+
if (!held?.remoteId) return send(422, { error: `we do not know where ${onAcct.handle} keeps that post` });
|
|
1165
|
+
try {
|
|
1166
|
+
await this.agent.fediaccts.api(onAcct.id,
|
|
1167
|
+
`/api/v1/statuses/${encodeURIComponent(held.remoteId)}`, { method: 'DELETE' });
|
|
1168
|
+
} catch (e) { return send(502, { error: e.message }); }
|
|
1169
|
+
this.store.removeStatus(s.noteId);
|
|
1170
|
+
return send(200, { ...rendered, text: s.content || '' });
|
|
1171
|
+
}
|
|
893
1172
|
// 502, because the refusal is the pod's: the client asked correctly and
|
|
894
1173
|
// the post is still up. Reporting 200 here is what let a deleted post
|
|
895
1174
|
// stay publicly readable with nothing to show for it.
|
|
@@ -1172,6 +1451,11 @@ export class MastoApi {
|
|
|
1172
1451
|
return send(200, this.status(patch ? (this.store.updateStatus(s.noteId, patch) || s) : s));
|
|
1173
1452
|
} catch (e) { return send(422, { error: e.message }); }
|
|
1174
1453
|
}
|
|
1454
|
+
// A post that reached us through a connected account is not ours to
|
|
1455
|
+
// address as the pod actor. Our own posts and our own inbox's timeline
|
|
1456
|
+
// keep the AP path even when a connected account also saw them: where
|
|
1457
|
+
// the pod identity has standing, it is the one that acts.
|
|
1458
|
+
if (s.kind === 'acct') return this.acctAction(send, s, mAction[2]);
|
|
1175
1459
|
const updated = await social[mAction[2]](this.agent, s);
|
|
1176
1460
|
return send(200, this.status(updated || s));
|
|
1177
1461
|
}
|
|
@@ -1455,7 +1739,7 @@ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c =>
|
|
|
1455
1739
|
const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+/))
|
|
1456
1740
|
.map(s => s.trim()).filter(Boolean);
|
|
1457
1741
|
|
|
1458
|
-
function sendLoginForm(res, params, error = '', client = null) {
|
|
1742
|
+
function sendLoginForm(res, params, error = '', client = null, status = null, headers = {}) {
|
|
1459
1743
|
const hidden = [...params.entries()].filter(([k]) => k !== 'password')
|
|
1460
1744
|
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`).join('\n');
|
|
1461
1745
|
// Name what is asking, so the owner approves a client they can see rather
|
|
@@ -1470,7 +1754,8 @@ function sendLoginForm(res, params, error = '', client = null) {
|
|
|
1470
1754
|
+ `${where ? `, sending the authorization to <code>${escapeHtml(where)}</code>` : ''}.</p>`
|
|
1471
1755
|
+ `<p>Scope: <code>${escapeHtml(client.scope || 'read')}</code>. Enter the agent password to allow it.</p>`;
|
|
1472
1756
|
}
|
|
1473
|
-
res.writeHead(error ? 401 : 200,
|
|
1757
|
+
res.writeHead(status || (error ? 401 : 200),
|
|
1758
|
+
{ 'content-type': 'text/html; charset=utf-8', ...headers });
|
|
1474
1759
|
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1475
1760
|
<title>FediPod — authorize</title>
|
|
1476
1761
|
<style>:root{color-scheme:light dark;font-size:125%;--heading:#1a4f8a}
|
package/lib/publisher.mjs
CHANGED
|
@@ -23,7 +23,7 @@ const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
|
23
23
|
|
|
24
24
|
export class Publisher {
|
|
25
25
|
constructor({ config, remote, local, store, deliverer, publicKeyPem, assertionKey = null, log = console.log,
|
|
26
|
-
probeFetch = null, resolveMention = null, privateOnPod = true,
|
|
26
|
+
probeFetch = null, resolveMention = null, privateOnPod = true, clientOrigin = null,
|
|
27
27
|
}) {
|
|
28
28
|
this.config = config;
|
|
29
29
|
this.remote = remote;
|
|
@@ -32,6 +32,10 @@ export class Publisher {
|
|
|
32
32
|
this.deliverer = deliverer;
|
|
33
33
|
this.publicKeyPem = publicKeyPem;
|
|
34
34
|
this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
|
|
35
|
+
// Where this identity's client surface answers, when that is an address a
|
|
36
|
+
// stranger can reach. Null on a laptop, where the surface is on loopback
|
|
37
|
+
// and advertising it to the world would name somewhere nobody can go.
|
|
38
|
+
this.clientOrigin = clientOrigin;
|
|
35
39
|
// A fronted identity (config.gateway.frontActor) advertises its ids on a
|
|
36
40
|
// shared domain; the map tells RemotePod where each writes on the pod.
|
|
37
41
|
const publicBase = config.gateway?.frontActor
|
|
@@ -88,6 +92,13 @@ export class Publisher {
|
|
|
88
92
|
pendingFollowing: priv ? urls.pendingFollowing : null,
|
|
89
93
|
blocked: priv ? urls.blocked : null,
|
|
90
94
|
inbox: gwActive ? gw.url : null,
|
|
95
|
+
// The agent's own outbox endpoint, where it is reachable: a client
|
|
96
|
+
// following the actor must arrive somewhere that will take a write.
|
|
97
|
+
outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
|
|
98
|
+
// How a client-to-server client finds the way in with nothing configured
|
|
99
|
+
// by hand. Advertised only where the surface is publicly reachable.
|
|
100
|
+
oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
|
|
101
|
+
oauthToken: this.clientOrigin ? `${this.clientOrigin}oauth/token` : null,
|
|
91
102
|
});
|
|
92
103
|
const surface = crypto.createHash('sha256').update(JSON.stringify({
|
|
93
104
|
actor: actorDoc, handle: this.config.handle, host,
|
package/lib/remote.mjs
CHANGED
|
@@ -29,6 +29,12 @@ export { mintCredential, discoverTokenEndpoint, revokeCredentialViaAccount };
|
|
|
29
29
|
// opened — and the callers' existing retry paths take it from there.
|
|
30
30
|
// The parser is shared with the outbound path; see lib/safefetch.mjs.
|
|
31
31
|
import { retryAfterMs, readCapped } from './safefetch.mjs';
|
|
32
|
+
import { linkTargets, REL } from './links.mjs';
|
|
33
|
+
|
|
34
|
+
// A pod whose access rules are ACP policies, not WAC authorizations. This
|
|
35
|
+
// agent writes WAC; over an ACP resource that would be noise where the pod's
|
|
36
|
+
// real rules used to be, so it stops instead.
|
|
37
|
+
const ACP_NS = 'http://www.w3.org/ns/solid/acp#';
|
|
32
38
|
|
|
33
39
|
// The inbox is public-Append, so the listing's size is in other people's
|
|
34
40
|
// hands; reading it whole must still have a ceiling.
|
|
@@ -82,6 +88,11 @@ export class RemotePod {
|
|
|
82
88
|
this.pausedUntil = 0;
|
|
83
89
|
this.probeCount = 0;
|
|
84
90
|
this.log = log;
|
|
91
|
+
// Where each resource's access control lives, as the pod itself said. WAC
|
|
92
|
+
// forbids working it out from the resource's own URL, so it is asked for
|
|
93
|
+
// and remembered rather than assembled.
|
|
94
|
+
this.aclUrls = new Map();
|
|
95
|
+
this.aclFlavour = null; // null until the first write asks what this pod speaks
|
|
85
96
|
// A fronted identity advertises ids on a shared domain but writes to the
|
|
86
97
|
// pod. run-agent installs the fronted→pod mapping here, so every request
|
|
87
98
|
// built from an advertised id lands on the pod — one choke point, and
|
|
@@ -153,9 +164,63 @@ export class RemotePod {
|
|
|
153
164
|
method: 'PUT', headers: { 'content-type': contentType }, body,
|
|
154
165
|
});
|
|
155
166
|
if (res.status >= 400) throw new Error(`PUT ${url} → ${res.status}`);
|
|
167
|
+
// Writing a document is usually the step before setting its access, and
|
|
168
|
+
// the answer to the write already says where that lives. Taking it here
|
|
169
|
+
// spares the extra request the ACL write would otherwise make.
|
|
170
|
+
this.noteAclLink(url, res);
|
|
156
171
|
return res;
|
|
157
172
|
}
|
|
158
173
|
|
|
174
|
+
/** Remember an access-control location the pod volunteered on a response. */
|
|
175
|
+
noteAclLink(url, res) {
|
|
176
|
+
if (this.aclUrls.has(url)) return;
|
|
177
|
+
const [acl] = linkTargets(res?.headers?.get?.('link'), REL.acl, url);
|
|
178
|
+
if (acl) this.aclUrls.set(url, acl);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Where this resource's access control lives. The pod says so on any
|
|
183
|
+
* response about the resource; a pod that says nothing is taken to keep it
|
|
184
|
+
* at the usual suffix, which is what every server this runs against does.
|
|
185
|
+
*/
|
|
186
|
+
async aclUrlFor(targetUrl) {
|
|
187
|
+
const known = this.aclUrls.get(targetUrl);
|
|
188
|
+
if (known) return known;
|
|
189
|
+
try {
|
|
190
|
+
const res = await this.fetch(targetUrl, { method: 'HEAD' });
|
|
191
|
+
this.noteAclLink(targetUrl, res);
|
|
192
|
+
} catch { /* unreachable or no such resource yet: the suffix below */ }
|
|
193
|
+
const resolved = this.aclUrls.get(targetUrl) || targetUrl + '.acl';
|
|
194
|
+
this.aclUrls.set(targetUrl, resolved);
|
|
195
|
+
return resolved;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Whether writing a WAC document here is meaningful. Asked once per pod, on
|
|
200
|
+
* the first access-control write. A pod that answers with ACP policies is
|
|
201
|
+
* left alone: replacing them with authorizations it does not read would take
|
|
202
|
+
* away the rules actually protecting it.
|
|
203
|
+
*/
|
|
204
|
+
async aclWritable(aclUrl) {
|
|
205
|
+
if (this.aclFlavour !== null) return this.aclFlavour;
|
|
206
|
+
this.aclFlavour = true;
|
|
207
|
+
try {
|
|
208
|
+
const res = await this.fetch(aclUrl, { headers: { accept: 'text/turtle' } });
|
|
209
|
+
if (res.status < 300) {
|
|
210
|
+
const g = $rdf.graph();
|
|
211
|
+
$rdf.parse(await res.text(), g, aclUrl, 'text/turtle');
|
|
212
|
+
const acp = g.statements.some(st => st.predicate.value.startsWith(ACP_NS)
|
|
213
|
+
|| st.object.value.startsWith(ACP_NS));
|
|
214
|
+
if (acp) {
|
|
215
|
+
this.aclFlavour = false;
|
|
216
|
+
this.log('this pod states access as ACP policies, which this agent does not write — '
|
|
217
|
+
+ 'its access rules are left exactly as they are, and nothing here is published private');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch { /* absent, unreadable or unparsable: WAC is what we write */ }
|
|
221
|
+
return this.aclFlavour;
|
|
222
|
+
}
|
|
223
|
+
|
|
159
224
|
async putJson(url, obj, contentType = 'application/activity+json') {
|
|
160
225
|
return this.put(url, JSON.stringify(obj), contentType);
|
|
161
226
|
}
|
|
@@ -173,6 +238,11 @@ export class RemotePod {
|
|
|
173
238
|
|
|
174
239
|
async delete(url) {
|
|
175
240
|
protectedFromDeletion(url);
|
|
241
|
+
// The pattern list above knows the usual name for an access-control
|
|
242
|
+
// document. One the pod named itself is just as fatal to remove.
|
|
243
|
+
for (const acl of this.aclUrls.values()) {
|
|
244
|
+
if (acl === url) throw new Error(`refusing to DELETE an access-control document: ${url}`);
|
|
245
|
+
}
|
|
176
246
|
const res = await this.fetch(url, { method: 'DELETE' });
|
|
177
247
|
return res.status < 400 || res.status === 404;
|
|
178
248
|
}
|
|
@@ -238,8 +308,8 @@ export class RemotePod {
|
|
|
238
308
|
// $rdf.sym() also throws on an illegal IRI, so a pod URL with something odd
|
|
239
309
|
// in it fails here rather than silently producing a document that means
|
|
240
310
|
// something else.
|
|
241
|
-
aclDoc(targetUrl, publicModes, { appendAgents = [] } = {}) {
|
|
242
|
-
const url = targetUrl + '.acl';
|
|
311
|
+
aclDoc(targetUrl, publicModes, { appendAgents = [], aclUrl = null } = {}) {
|
|
312
|
+
const url = aclUrl || targetUrl + '.acl';
|
|
243
313
|
const doc = $rdf.sym(url);
|
|
244
314
|
const target = $rdf.sym(targetUrl);
|
|
245
315
|
const g = $rdf.graph();
|
|
@@ -263,7 +333,9 @@ export class RemotePod {
|
|
|
263
333
|
}
|
|
264
334
|
|
|
265
335
|
async setAcl(targetUrl, publicModes, opts = {}) {
|
|
266
|
-
|
|
336
|
+
const url = await this.aclUrlFor(targetUrl);
|
|
337
|
+
if (!await this.aclWritable(url)) return null;
|
|
338
|
+
return this.put(url, this.aclDoc(targetUrl, publicModes, { ...opts, aclUrl: url }), 'text/turtle');
|
|
267
339
|
}
|
|
268
340
|
|
|
269
341
|
// The WebID profile advertises the actor as an account:
|
|
@@ -295,9 +367,53 @@ export class RemotePod {
|
|
|
295
367
|
const stale = g.statementsMatching(actor, FOAF('accountName'), null, doc)
|
|
296
368
|
.filter(st => st.object.value !== accountName);
|
|
297
369
|
if (!missing.length && !stale.length) return false;
|
|
370
|
+
// A patch touches these statements and nothing else. Rewriting the whole
|
|
371
|
+
// profile re-serialises statements that are not ours — the OIDC issuer
|
|
372
|
+
// among them — and a server is entitled to refuse a write that would.
|
|
373
|
+
const deletes = stale.map(st => [ st.subject, st.predicate, st.object ]);
|
|
374
|
+
if (await this.patchDocument(docUrl, missing, deletes)) return true;
|
|
298
375
|
for (const st of stale) g.remove(st);
|
|
299
376
|
for (const [s, p, o] of missing) g.add(s, p, o, doc);
|
|
300
377
|
await this.put(docUrl, $rdf.serialize(doc, g, docUrl, 'text/turtle'), 'text/turtle');
|
|
301
378
|
return true;
|
|
302
379
|
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* An N3 Patch of exactly these statements, or false when the pod will not
|
|
383
|
+
* take one and the caller should write the document instead.
|
|
384
|
+
*
|
|
385
|
+
* The statements are serialised by rdflib; only the wrapper naming what is
|
|
386
|
+
* being patched is assembled here, because N3's braces have no rdflib form.
|
|
387
|
+
*/
|
|
388
|
+
n3Patch(docUrl, inserts, deletes) {
|
|
389
|
+
const block = (triples) => {
|
|
390
|
+
const g = $rdf.graph();
|
|
391
|
+
for (const [s, p, o] of triples) g.add(s, p, o);
|
|
392
|
+
return $rdf.serialize(null, g, docUrl, 'application/n-triples').trim();
|
|
393
|
+
};
|
|
394
|
+
const clauses = [];
|
|
395
|
+
if (deletes.length) clauses.push(` solid:deletes { ${block(deletes)} }`);
|
|
396
|
+
if (inserts.length) clauses.push(` solid:inserts { ${block(inserts)} }`);
|
|
397
|
+
return `@prefix solid: <http://www.w3.org/ns/solid/terms#>.\n`
|
|
398
|
+
+ `<> a solid:InsertDeletePatch;\n${clauses.join(';\n')}.\n`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async patchDocument(docUrl, inserts, deletes) {
|
|
402
|
+
let res;
|
|
403
|
+
try {
|
|
404
|
+
res = await this.fetch(docUrl, {
|
|
405
|
+
method: 'PATCH',
|
|
406
|
+
headers: { 'content-type': 'text/n3' },
|
|
407
|
+
body: this.n3Patch(docUrl, inserts, deletes),
|
|
408
|
+
});
|
|
409
|
+
} catch {
|
|
410
|
+
return false; // no PATCH on this transport at all
|
|
411
|
+
}
|
|
412
|
+
if (res.status < 300) return true;
|
|
413
|
+
// The pod cannot patch. Anything else — a 409 saying what we meant to
|
|
414
|
+
// remove is not there any more — is a real answer, and rewriting the whole
|
|
415
|
+
// document over the top of it would destroy whatever changed it.
|
|
416
|
+
if (res.status === 405 || res.status === 415 || res.status === 501) return false;
|
|
417
|
+
throw new Error(`PATCH ${docUrl} → ${res.status}`);
|
|
418
|
+
}
|
|
303
419
|
}
|