fedipod-server 0.28.0 → 0.29.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 +27 -28
- package/config/server.json +22 -0
- package/dist/claims.js +3 -2
- package/dist/directory.d.ts +2 -0
- package/dist/handler.d.ts +4 -1
- package/dist/handler.js +32 -3
- package/dist/handler.jsonld +4 -0
- package/dist/store-pod.js +37 -0
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +68 -22
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +50 -6
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/lease.mjs +7 -7
- package/lib/core/place.mjs +82 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +23 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/social.mjs +62 -32
- package/lib/core/store.mjs +36 -6
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +80 -111
- package/lib/gateway/gateway-core.mjs +91 -9
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +207 -25
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/README.md +14 -0
- package/lib/session/fedi-account.mjs +73 -2
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +18 -0
- package/web/admin/actors.js +2 -0
- package/web/admin/index.html +9 -0
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +1 -1
- package/web/app/admin-facade.mjs +1 -1
- package/web/app/agent.mjs +24 -10
- package/web/app/boot.mjs +72 -32
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +466 -89
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +2792 -1996
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/app/update.js +4 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
package/lib/core/social.mjs
CHANGED
|
@@ -126,7 +126,7 @@ async function cacheCounts(agent, doc) {
|
|
|
126
126
|
|
|
127
127
|
// `doc` skips the fetch when the caller already resolved the actor; a bulk
|
|
128
128
|
// import passes publish:false and republishes the collections once at the end.
|
|
129
|
-
export async function followActor(agent, actorUrl, { publish = true, doc: fetched = null } = {}) {
|
|
129
|
+
export async function followActor(agent, actorUrl, { publish = true, doc: fetched = null, serial = Date.now() } = {}) {
|
|
130
130
|
const doc = fetched || await agent.intake.fetchAP(actorUrl);
|
|
131
131
|
if (!doc?.inbox) throw new Error(`actor document unusable (${actorUrl})`);
|
|
132
132
|
if (agent.store.isBlocked(doc.id)) throw new Error('actor is blocked');
|
|
@@ -136,7 +136,7 @@ export async function followActor(agent, actorUrl, { publish = true, doc: fetche
|
|
|
136
136
|
rec = { actor: doc.id, inbox: doc.inbox, accepted: false };
|
|
137
137
|
contacts.following.push(rec);
|
|
138
138
|
}
|
|
139
|
-
const follow = wire.followActivity({ urls: agent.publisher.urls, targetActor: doc.id, serial
|
|
139
|
+
const follow = wire.followActivity({ urls: agent.publisher.urls, targetActor: doc.id, serial });
|
|
140
140
|
rec.followActivity = follow; // kept for a later Undo
|
|
141
141
|
agent.store.setContacts(contacts);
|
|
142
142
|
await agent.deliverer.deliver(doc.inbox, follow);
|
|
@@ -144,9 +144,9 @@ export async function followActor(agent, actorUrl, { publish = true, doc: fetche
|
|
|
144
144
|
return doc;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
export async function followHandle(agent, handle) {
|
|
147
|
+
export async function followHandle(agent, handle, { serial = Date.now() } = {}) {
|
|
148
148
|
const doc = await resolveHandle(agent, handle);
|
|
149
|
-
await followActor(agent, doc.id);
|
|
149
|
+
await followActor(agent, doc.id, { serial });
|
|
150
150
|
const clean = String(handle || '').replace(/^@/, '');
|
|
151
151
|
const contacts = agent.store.getContacts();
|
|
152
152
|
const rec = contacts.following.find(f => f.actor === doc.id);
|
|
@@ -154,13 +154,13 @@ export async function followHandle(agent, handle) {
|
|
|
154
154
|
return { ok: true, actor: doc.id };
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
export async function unfollowActor(agent, actor) {
|
|
157
|
+
export async function unfollowActor(agent, actor, { serial = Date.now() } = {}) {
|
|
158
158
|
const contacts = agent.store.getContacts();
|
|
159
159
|
const rec = contacts.following.find(f => f.actor === actor);
|
|
160
160
|
if (!rec) throw new Error('not following that actor');
|
|
161
161
|
if (rec.followActivity) {
|
|
162
162
|
await agent.deliverer.deliver(rec.inbox,
|
|
163
|
-
wire.undoActivity({ urls: agent.publisher.urls, activity: rec.followActivity, serial
|
|
163
|
+
wire.undoActivity({ urls: agent.publisher.urls, activity: rec.followActivity, serial }));
|
|
164
164
|
}
|
|
165
165
|
contacts.following = contacts.following.filter(f => f.actor !== actor);
|
|
166
166
|
agent.store.setContacts(contacts);
|
|
@@ -168,31 +168,31 @@ export async function unfollowActor(agent, actor) {
|
|
|
168
168
|
return { ok: true };
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
export async function favourite(agent, s) {
|
|
171
|
+
export async function favourite(agent, s, { serial = Date.now() } = {}) {
|
|
172
172
|
if (s.favourited) return s;
|
|
173
173
|
const doc = await agent.intake.fetchAP(s.actor);
|
|
174
174
|
if (!doc?.inbox) throw new Error('author inbox unavailable');
|
|
175
|
-
const act = wire.likeActivity({ urls: agent.publisher.urls, noteId: s.noteId, serial:
|
|
175
|
+
const act = wire.likeActivity({ urls: agent.publisher.urls, noteId: s.noteId, serial, to: s.actor });
|
|
176
176
|
await agent.deliverer.deliver(doc.endpoints?.sharedInbox || doc.inbox, act);
|
|
177
177
|
return agent.store.updateStatus(s.noteId, { favourited: true, likeActivity: act });
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
export async function unfavourite(agent, s) {
|
|
180
|
+
export async function unfavourite(agent, s, { serial = Date.now() } = {}) {
|
|
181
181
|
if (s.likeActivity) {
|
|
182
182
|
const doc = await agent.intake.fetchAP(s.actor).catch(() => null);
|
|
183
183
|
if (doc?.inbox) {
|
|
184
184
|
await agent.deliverer.deliver(doc.endpoints?.sharedInbox || doc.inbox,
|
|
185
|
-
wire.undoActivity({ urls: agent.publisher.urls, activity: s.likeActivity, serial
|
|
185
|
+
wire.undoActivity({ urls: agent.publisher.urls, activity: s.likeActivity, serial }));
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
return agent.store.updateStatus(s.noteId, { favourited: false, likeActivity: undefined });
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
// Announce goes to our followers (that's what boosting is) and to the author.
|
|
192
|
-
export async function reblog(agent, s) {
|
|
192
|
+
export async function reblog(agent, s, { serial = Date.now() } = {}) {
|
|
193
193
|
if (s.reblogged) return s;
|
|
194
194
|
const { urls } = agent.publisher;
|
|
195
|
-
const act = wire.announceActivity({ urls, object: s.noteId, serial
|
|
195
|
+
const act = wire.announceActivity({ urls, object: s.noteId, serial });
|
|
196
196
|
const inboxes = new Set(agent.store.getContacts().followers
|
|
197
197
|
.map(f => f.sharedInbox || f.inbox).filter(Boolean));
|
|
198
198
|
if (s.actor !== urls.actor) {
|
|
@@ -208,15 +208,15 @@ export async function reblog(agent, s) {
|
|
|
208
208
|
return updated;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
export async function unreblog(agent, s) {
|
|
211
|
+
export async function unreblog(agent, s, { serial = Date.now() } = {}) {
|
|
212
212
|
if (s.announceActivity) {
|
|
213
|
-
const undo = wire.undoActivity({ urls: agent.publisher.urls, activity: s.announceActivity, serial
|
|
213
|
+
const undo = wire.undoActivity({ urls: agent.publisher.urls, activity: s.announceActivity, serial });
|
|
214
214
|
const inboxes = new Set(agent.store.getContacts().followers
|
|
215
215
|
.map(f => f.sharedInbox || f.inbox).filter(Boolean));
|
|
216
216
|
const doc = await agent.intake.fetchAP(s.actor).catch(() => null);
|
|
217
217
|
if (doc?.inbox && s.actor !== agent.publisher.urls.actor) inboxes.add(doc.endpoints?.sharedInbox || doc.inbox);
|
|
218
218
|
await agent.deliverer.deliverToAll([...inboxes], undo);
|
|
219
|
-
await agent.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id);
|
|
219
|
+
await agent.publisher.unrecordOutbox(i => i?.id === s.announceActivity.id, { record: () => undo });
|
|
220
220
|
}
|
|
221
221
|
return agent.store.updateStatus(s.noteId, { reblogged: false, announceActivity: undefined });
|
|
222
222
|
}
|
|
@@ -260,7 +260,7 @@ export async function ejectFollower(agent, actor) {
|
|
|
260
260
|
|
|
261
261
|
// Group: answer a held join request. Admitting is the Accept onFollow would
|
|
262
262
|
// have sent immediately had the group not been gated.
|
|
263
|
-
export async function admitRequest(agent, actor, { publish = true } = {}) {
|
|
263
|
+
export async function admitRequest(agent, actor, { publish = true, serial = Date.now() } = {}) {
|
|
264
264
|
const reqs = agent.store.getRequests();
|
|
265
265
|
const req = reqs.find(r => r.actor === actor);
|
|
266
266
|
if (!req) throw new Error('no such request');
|
|
@@ -286,14 +286,14 @@ export async function admitRequest(agent, actor, { publish = true } = {}) {
|
|
|
286
286
|
// A bulk admit passes publish:false and republishes once for the lot.
|
|
287
287
|
if (publish) await agent.publisher.publishCollections({ followers: true, pending: true });
|
|
288
288
|
await agent.deliverer.deliver(req.inbox, wire.acceptActivity({
|
|
289
|
-
urls: agent.publisher.urls, followActivity: req.activity, serial
|
|
289
|
+
urls: agent.publisher.urls, followActivity: req.activity, serial,
|
|
290
290
|
}));
|
|
291
291
|
return { ok: true, actor };
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
// Refusing is not a ban: they may ask again, and the queue is where you would
|
|
295
295
|
// see that. Eject is the sticky one, because it mutes.
|
|
296
|
-
export async function refuseRequest(agent, actor) {
|
|
296
|
+
export async function refuseRequest(agent, actor, { serial = Date.now() } = {}) {
|
|
297
297
|
const reqs = agent.store.getRequests();
|
|
298
298
|
const req = reqs.find(r => r.actor === actor);
|
|
299
299
|
if (!req) throw new Error('no such request');
|
|
@@ -307,7 +307,7 @@ export async function refuseRequest(agent, actor) {
|
|
|
307
307
|
return { ok: true, actor };
|
|
308
308
|
}
|
|
309
309
|
await agent.deliverer.deliver(req.inbox, wire.rejectActivity({
|
|
310
|
-
urls: agent.publisher.urls, followActivity: req.activity, serial
|
|
310
|
+
urls: agent.publisher.urls, followActivity: req.activity, serial,
|
|
311
311
|
}));
|
|
312
312
|
return { ok: true, actor };
|
|
313
313
|
}
|
|
@@ -375,9 +375,21 @@ export async function votePoll(agent, s, choices) {
|
|
|
375
375
|
|
|
376
376
|
export async function deleteNote(agent, s) {
|
|
377
377
|
const { urls } = agent.publisher;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
378
|
+
// The deletion goes where the post went: followers unless it was direct, and
|
|
379
|
+
// everyone it named or was blind-copied to. Addressed as the post was, read
|
|
380
|
+
// from the post itself while it is still there.
|
|
381
|
+
const doc = await Promise.resolve().then(() => podNotes.read(agent.remote, s.noteId)).catch(() => null);
|
|
382
|
+
const shape = wire.addressing(urls, s.visibility || 'public', (s.mentions || []).map(m => m.href).filter(Boolean));
|
|
383
|
+
const to = Array.isArray(doc?.to) ? doc.to : shape.to;
|
|
384
|
+
const cc = Array.isArray(doc?.cc) ? doc.cc : shape.cc;
|
|
385
|
+
const named = [...to, ...cc, ...(s.addressed || []), ...(s.replyActor ? [s.replyActor] : [])]
|
|
386
|
+
.filter(a => typeof a === 'string' && a !== wire.PUBLIC && a !== urls.followers && a !== urls.actor);
|
|
387
|
+
const inboxes = [
|
|
388
|
+
...(s.visibility === 'direct' ? [] : agent.store.getContacts().followers.map(f => f.sharedInbox || f.inbox)),
|
|
389
|
+
...(agent.publisher.inboxesFor ? await agent.publisher.inboxesFor(named).catch(() => []) : []),
|
|
390
|
+
].filter(Boolean);
|
|
391
|
+
const del = wire.deleteActivity({ urls, noteId: s.noteId, to, cc });
|
|
392
|
+
await agent.deliverer.deliverToAll(inboxes, del);
|
|
381
393
|
|
|
382
394
|
// The note is REPLACED with a Tombstone rather than removed, so a peer that
|
|
383
395
|
// dereferences it learns the post was deleted instead of a bare 404 (§7.4).
|
|
@@ -386,12 +398,23 @@ export async function deleteNote(agent, s) {
|
|
|
386
398
|
// not the object, so it is deleted outright.
|
|
387
399
|
const stuck = [];
|
|
388
400
|
const deletedAt = new Date().toISOString();
|
|
401
|
+
// Only inside the posts folders: an object that lives elsewhere on the pod
|
|
402
|
+
// (an annotation a client keeps in its own place, or anything it named) is
|
|
403
|
+
// that client's document, and a deletion here must not overwrite it — the
|
|
404
|
+
// profile, say — with a Tombstone. Its Create and outbox entry still go.
|
|
405
|
+
const ours = [urls.notes, urls.privateNotes].some(c => String(s.noteId).startsWith(c));
|
|
389
406
|
try {
|
|
390
|
-
await podNotes.writeTombstone(agent.remote, s.noteId, wire.noteTombstone(s.noteId, deletedAt));
|
|
407
|
+
if (ours) await podNotes.writeTombstone(agent.remote, s.noteId, wire.noteTombstone(s.noteId, deletedAt));
|
|
391
408
|
} catch { stuck.push(s.noteId); }
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
409
|
+
// The Create becomes a Tombstone too, not a gap: it embedded the post, so
|
|
410
|
+
// its words go, and whoever asks for it is told it is gone (410 through the
|
|
411
|
+
// Gateway) rather than that it never was.
|
|
412
|
+
// An object kept outside the posts folders has its Create inside them, under
|
|
413
|
+
// the name it was given (publishObject).
|
|
414
|
+
const createId = ours ? wire.createActivityId(s.noteId) : (s.slug ? wire.createActivityId(urls.notes + s.slug) : null);
|
|
415
|
+
try {
|
|
416
|
+
if (createId) await podNotes.writeTombstone(agent.remote, createId, wire.noteTombstone(createId, deletedAt, 'Create'));
|
|
417
|
+
} catch { stuck.push(createId); }
|
|
395
418
|
if (stuck.length) {
|
|
396
419
|
agent.log?.(`delete ${s.noteId}: the pod kept ${stuck.length} document(s) — the post is STILL PUBLISHED`);
|
|
397
420
|
return {
|
|
@@ -410,8 +433,15 @@ export async function deleteNote(agent, s) {
|
|
|
410
433
|
.catch(e => agent.log?.(`bluesky mirror not deleted (${e.message}): ${s.atproto.uri}`));
|
|
411
434
|
}
|
|
412
435
|
|
|
413
|
-
// The note's outbox entry, and our own boost of it if there was one.
|
|
414
|
-
|
|
436
|
+
// The note's outbox entry, and our own boost of it if there was one. The
|
|
437
|
+
// Delete takes the post's place — only for a post the outbox listed, so a
|
|
438
|
+
// private post's deletion stays as private as the post was.
|
|
439
|
+
const itsOwn = (i) => i === s.noteId || (i?.id && i.id === s.announceActivity?.id)
|
|
440
|
+
// its edits too: an Update carries the post's words, which must not
|
|
441
|
+
// outlive the post on the public record
|
|
442
|
+
|| (i?.type === 'Update' && (typeof i.object === 'string' ? i.object : i.object?.id) === s.noteId);
|
|
443
|
+
await agent.publisher.unrecordOutbox(itsOwn,
|
|
444
|
+
{ record: (gone) => (gone.includes(s.noteId) ? del : null) });
|
|
415
445
|
agent.store.removeStatus(s.noteId);
|
|
416
446
|
return { ok: true };
|
|
417
447
|
}
|
|
@@ -436,7 +466,7 @@ export async function announceModeration(agent, activity) {
|
|
|
436
466
|
// nothing is delivered TO THE BLOCKED PARTY (§6.10). Intake already refuses a
|
|
437
467
|
// blocked author on arrival. A group additionally announces the ban to its
|
|
438
468
|
// members (FEP-1b12) — announceModeration keeps a person's blocks silent.
|
|
439
|
-
export async function blockActor(agent, actorUrl) {
|
|
469
|
+
export async function blockActor(agent, actorUrl, { serial = Date.now() } = {}) {
|
|
440
470
|
if (!/^https?:\/\/\S+$/.test(String(actorUrl || ''))) throw new Error('actor must be a URL');
|
|
441
471
|
const b = agent.store.getBlocklist();
|
|
442
472
|
if (!b.actors.includes(actorUrl)) b.actors.push(actorUrl);
|
|
@@ -444,12 +474,12 @@ export async function blockActor(agent, actorUrl) {
|
|
|
444
474
|
await unfollowActor(agent, actorUrl).catch(() => {}); // not following is fine
|
|
445
475
|
await agent.publisher?.publishCollections({ blocked: true }).catch(() => {});
|
|
446
476
|
await announceModeration(agent,
|
|
447
|
-
wire.blockActivity({ urls: agent.publisher.urls, targetActor: actorUrl, serial
|
|
477
|
+
wire.blockActivity({ urls: agent.publisher.urls, targetActor: actorUrl, serial }))
|
|
448
478
|
.catch(e => agent.log?.(`moderation announce failed: ${e.message}`));
|
|
449
479
|
return { ok: true, actor: actorUrl };
|
|
450
480
|
}
|
|
451
481
|
|
|
452
|
-
export async function unblockActor(agent, actorUrl) {
|
|
482
|
+
export async function unblockActor(agent, actorUrl, { serial = Date.now() } = {}) {
|
|
453
483
|
const b = agent.store.getBlocklist();
|
|
454
484
|
b.actors = b.actors.filter(a => a !== actorUrl);
|
|
455
485
|
agent.store.setBlocklist(b);
|
|
@@ -459,7 +489,7 @@ export async function unblockActor(agent, actorUrl) {
|
|
|
459
489
|
await announceModeration(agent, wire.undoActivity({
|
|
460
490
|
urls: agent.publisher.urls,
|
|
461
491
|
activity: { type: 'Block', actor: agent.publisher.urls.actor, object: actorUrl },
|
|
462
|
-
serial
|
|
492
|
+
serial,
|
|
463
493
|
})).catch(e => agent.log?.(`moderation announce failed: ${e.message}`));
|
|
464
494
|
return { ok: true, actor: actorUrl };
|
|
465
495
|
}
|
package/lib/core/store.mjs
CHANGED
|
@@ -72,6 +72,11 @@ export function dropFollower(contacts, actor, why) {
|
|
|
72
72
|
return contacts;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// What a state document looks like on the pod; write() and _put() agree on it.
|
|
76
|
+
const serialise = (obj) => JSON.stringify(obj, null, 2) + '\n';
|
|
77
|
+
// The same record apart from when it was fetched.
|
|
78
|
+
const sameButWhen = (a, b) => JSON.stringify({ ...a, fetchedAt: null }) === JSON.stringify({ ...b, fetchedAt: null });
|
|
79
|
+
|
|
75
80
|
export class PodStore {
|
|
76
81
|
constructor({ storage = null, log = console.log } = {}) {
|
|
77
82
|
this.lastSkipped = []; // what the last load could not read, as `name (HTTP n)`
|
|
@@ -79,6 +84,7 @@ export class PodStore {
|
|
|
79
84
|
this.log = log;
|
|
80
85
|
this.cache = new Map(); // name → parsed value
|
|
81
86
|
this.etags = new Map(); // name (or '') → last ETag, for revalidation
|
|
87
|
+
this.lastText = new Map(); // name → the bytes last loaded from or sent to the pod
|
|
82
88
|
this.timers = new Map(); // name → debounce timer
|
|
83
89
|
this.dirty = new Set(); // written while held; flushed by commit/release
|
|
84
90
|
this._held = 0;
|
|
@@ -91,7 +97,7 @@ export class PodStore {
|
|
|
91
97
|
attach(storage) {
|
|
92
98
|
// A different tree is different state: carrying the old cache and its
|
|
93
99
|
// ETags across would serve one container's documents as another's.
|
|
94
|
-
if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); }
|
|
100
|
+
if (this.storage && this.storage.base !== storage.base) { this.cache.clear(); this.etags.clear(); this.lastText.clear(); }
|
|
95
101
|
this.storage = storage;
|
|
96
102
|
}
|
|
97
103
|
|
|
@@ -144,7 +150,7 @@ export class PodStore {
|
|
|
144
150
|
}
|
|
145
151
|
fetched++;
|
|
146
152
|
this.etags.set(name, r.etag);
|
|
147
|
-
try { this.cache.set(name, JSON.parse(r.body)); }
|
|
153
|
+
try { this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); }
|
|
148
154
|
catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
|
|
149
155
|
}
|
|
150
156
|
// Kept for /status: a document skipped here is a timeline or a contact
|
|
@@ -167,6 +173,15 @@ export class PodStore {
|
|
|
167
173
|
write(name, obj) {
|
|
168
174
|
this.cache.set(name, structuredClone(obj));
|
|
169
175
|
if (!this.storage) return;
|
|
176
|
+
// The same bytes the pod already holds are not sent again. A like, a
|
|
177
|
+
// bookmark, a sweep that found nothing new each used to upload the whole
|
|
178
|
+
// timeline index and the whole people cache, unchanged — the largest
|
|
179
|
+
// write on the pod, on every small action. A write that had been armed
|
|
180
|
+
// and is now undone is unarmed with it.
|
|
181
|
+
if (this.lastText.get(name) === serialise(obj)) {
|
|
182
|
+
clearTimeout(this.timers.get(name)); this.timers.delete(name); this.dirty.delete(name);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
170
185
|
// Held: record it and leave the writing to the commit boundary the caller
|
|
171
186
|
// already has. See hold().
|
|
172
187
|
if (this._held) { this.dirty.add(name); return; }
|
|
@@ -205,13 +220,13 @@ export class PodStore {
|
|
|
205
220
|
// the returned promise so one failure cannot poison the queue.
|
|
206
221
|
_put(name) {
|
|
207
222
|
const done = this.chain.then(async () => {
|
|
208
|
-
const body =
|
|
223
|
+
const body = serialise(this.cache.get(name));
|
|
209
224
|
for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
|
|
210
225
|
// A storage that throws is a bug, not a hiccup — but it must not
|
|
211
226
|
// escape into the write queue, where it would look like success.
|
|
212
227
|
const r = await this.storage.write(name, body, 'application/json')
|
|
213
228
|
.catch(e => ({ ok: false, retry: false, why: e.message }));
|
|
214
|
-
if (r.ok) return true;
|
|
229
|
+
if (r.ok) { this.lastText.set(name, body); return true; }
|
|
215
230
|
// The storage says whether trying again could possibly help: a pod's
|
|
216
231
|
// 4xx is an answer rather than a hiccup, and a directory's EACCES will
|
|
217
232
|
// still be an EACCES in two seconds.
|
|
@@ -236,6 +251,7 @@ export class PodStore {
|
|
|
236
251
|
// migrates to the local machine — leaving the copy behind would defeat it).
|
|
237
252
|
async remove(name) {
|
|
238
253
|
this.cache.delete(name);
|
|
254
|
+
this.lastText.delete(name);
|
|
239
255
|
clearTimeout(this.timers.get(name));
|
|
240
256
|
this.timers.delete(name);
|
|
241
257
|
if (!this.storage) return true;
|
|
@@ -289,7 +305,17 @@ export class PodStore {
|
|
|
289
305
|
const b = this.read('blocklist.json', {});
|
|
290
306
|
return { domains: b.domains || [], actors: b.actors || [] };
|
|
291
307
|
}
|
|
292
|
-
|
|
308
|
+
// Blocking someone ends their following as well: a blocked actor, or anyone
|
|
309
|
+
// on a blocked domain, is no longer delivered to. The mark dropFollower leaves
|
|
310
|
+
// keeps a reconcile from bringing them back.
|
|
311
|
+
setBlocklist(b) {
|
|
312
|
+
this.write('blocklist.json', b);
|
|
313
|
+
const c = this.getContacts();
|
|
314
|
+
const gone = c.followers.filter(f => f.actor && this.isBlocked(f.actor)).map(f => f.actor);
|
|
315
|
+
if (!gone.length) return;
|
|
316
|
+
for (const actor of gone) dropFollower(c, actor, 'blocked');
|
|
317
|
+
this.setContacts(c);
|
|
318
|
+
}
|
|
293
319
|
// Takes an actor URL or an object URL: the actor list only ever matches the
|
|
294
320
|
// former, the domain list matches either.
|
|
295
321
|
isBlocked(url) {
|
|
@@ -462,6 +488,7 @@ export class PodStore {
|
|
|
462
488
|
getActors() { return this.read('actors.json', {}); }
|
|
463
489
|
cacheActor(url, doc) {
|
|
464
490
|
const a = this.getActors();
|
|
491
|
+
const known = a[url];
|
|
465
492
|
a[url] = {
|
|
466
493
|
name: clamp(plainText(doc.name || doc.preferredUsername || ''), MAX_NAME),
|
|
467
494
|
preferredUsername: clamp(plainText(doc.preferredUsername || ''), MAX_NAME),
|
|
@@ -474,9 +501,12 @@ export class PodStore {
|
|
|
474
501
|
type: doc.type || 'Person',
|
|
475
502
|
followers: doc.followers || null,
|
|
476
503
|
following: doc.following || null,
|
|
477
|
-
...(
|
|
504
|
+
...(known?.counts ? { counts: known.counts } : {}),
|
|
478
505
|
fetchedAt: new Date().toISOString(),
|
|
479
506
|
};
|
|
507
|
+
// Fetched again and found the same: nothing to write. Only the time of
|
|
508
|
+
// asking differs, and that is not worth the whole cache going to the pod.
|
|
509
|
+
if (known && sameButWhen(known, a[url])) { a[url] = known; return; }
|
|
480
510
|
this.write('actors.json', prune(a, ACTOR_CACHE_MAX, this.getContacts()));
|
|
481
511
|
}
|
|
482
512
|
|
package/lib/core/wire.mjs
CHANGED
|
@@ -83,7 +83,8 @@ export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
|
|
|
83
83
|
|
|
84
84
|
export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null, movedTo = null, kind = 'person',
|
|
85
85
|
approveJoins = false, summary = null, icon = null, image = null, fields = [],
|
|
86
|
-
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null,
|
|
86
|
+
webId = null, aliases = [], moderators = null, pendingFollowers = null, pendingFollowing = null, liked = null,
|
|
87
|
+
ownerOutbox = null,
|
|
87
88
|
blocked = null, inbox = null, outbox = null, oauthAuthorize = null, oauthToken = null,
|
|
88
89
|
postingRestrictedToMods = null }) {
|
|
89
90
|
// manuallyApprovesFollowers is NOT in the base AS2 context, so it is declared
|
|
@@ -119,6 +120,12 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
|
|
|
119
120
|
blocked: { '@id': 'bl:blocked', '@type': '@id' },
|
|
120
121
|
});
|
|
121
122
|
}
|
|
123
|
+
if (ownerOutbox) {
|
|
124
|
+
context.push({
|
|
125
|
+
fedipod: 'https://fedipod.net/ns#',
|
|
126
|
+
ownerOutbox: { '@id': 'fedipod:ownerOutbox', '@type': '@id' },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
122
129
|
// The actor's other identities: the account's WebID, plus any aliases —
|
|
123
130
|
// old accounts elsewhere whose servers check for their own URL here before
|
|
124
131
|
// they will send a Move. Declared inline exactly as Mastodon declares it.
|
|
@@ -193,6 +200,12 @@ export function actorDoc({ urls, handle, name, publicKeyPem, assertionKey = null
|
|
|
193
200
|
featured: urls.featured,
|
|
194
201
|
followers: urls.followers,
|
|
195
202
|
following: urls.following,
|
|
203
|
+
// What this actor has liked (§5.5). Its owner's to read, like the pending
|
|
204
|
+
// lists below.
|
|
205
|
+
...(liked ? { liked } : {}),
|
|
206
|
+
// Every message this actor produced, as its owner reads it (§5.1), at an
|
|
207
|
+
// address the owner's own credential reaches.
|
|
208
|
+
...(ownerOutbox ? { ownerOutbox } : {}),
|
|
196
209
|
// FEP-1b12: attributedTo names the moderators collection; recipients
|
|
197
210
|
// validate a group's announced moderation against it.
|
|
198
211
|
...(moderators ? { attributedTo: moderators } : {}),
|
|
@@ -252,12 +265,12 @@ export function tombstoneDoc(urls, deletedAt, kind = 'person') {
|
|
|
252
265
|
// A deleted note is left as a Tombstone at its own URL — kept fetchable — so a
|
|
253
266
|
// peer that dereferences it learns the post was DELETED rather than getting a
|
|
254
267
|
// bare 404 it cannot tell from "never existed" (§7.4).
|
|
255
|
-
export function noteTombstone(noteId, deletedAt) {
|
|
268
|
+
export function noteTombstone(noteId, deletedAt, formerType = 'Note') {
|
|
256
269
|
return {
|
|
257
270
|
'@context': AS_CTX,
|
|
258
271
|
id: noteId,
|
|
259
272
|
type: 'Tombstone',
|
|
260
|
-
formerType
|
|
273
|
+
formerType,
|
|
261
274
|
deleted: deletedAt,
|
|
262
275
|
};
|
|
263
276
|
}
|
|
@@ -418,25 +431,29 @@ export const FOLLOWERS_PAGE_SIZE = 20;
|
|
|
418
431
|
export const followersPageId = (id, n) => `${id}-${n}`;
|
|
419
432
|
export const followersPageCount = (total) => Math.max(1, Math.ceil(total / FOLLOWERS_PAGE_SIZE));
|
|
420
433
|
|
|
434
|
+
// Newest first, as every OrderedCollection is (ActivityPub §5): `first` is the
|
|
435
|
+
// page the newest names were added to and `next` walks back in time, and each
|
|
436
|
+
// page lists its names newest first. The pages themselves are still filled
|
|
437
|
+
// from the oldest end, so a new name rewrites one page.
|
|
421
438
|
export function followersHead(id, total, pageCount = followersPageCount(total)) {
|
|
422
439
|
const pages = Math.max(1, pageCount);
|
|
423
440
|
return {
|
|
424
441
|
'@context': AS_CTX,
|
|
425
442
|
id, type: 'OrderedCollection',
|
|
426
443
|
totalItems: total,
|
|
427
|
-
first: followersPageId(id,
|
|
428
|
-
last: followersPageId(id,
|
|
444
|
+
first: followersPageId(id, pages),
|
|
445
|
+
last: followersPageId(id, 1),
|
|
429
446
|
};
|
|
430
447
|
}
|
|
431
448
|
|
|
432
|
-
export function followersPage(id, n, items, pageCount) {
|
|
449
|
+
export function followersPage(id, n, items, pageCount) { // eslint-disable-line no-unused-vars
|
|
433
450
|
return {
|
|
434
451
|
'@context': AS_CTX,
|
|
435
452
|
id: followersPageId(id, n),
|
|
436
453
|
type: 'OrderedCollectionPage',
|
|
437
454
|
partOf: id,
|
|
438
|
-
orderedItems: items,
|
|
439
|
-
...(n
|
|
455
|
+
orderedItems: [...items].reverse(),
|
|
456
|
+
...(n > 1 ? { next: followersPageId(id, n - 1) } : {}),
|
|
440
457
|
};
|
|
441
458
|
}
|
|
442
459
|
|
|
@@ -775,12 +792,14 @@ export function followActivity({ urls, targetActor, serial }) {
|
|
|
775
792
|
};
|
|
776
793
|
}
|
|
777
794
|
|
|
778
|
-
|
|
795
|
+
// Addressed to the author of what is liked, the one it is delivered to.
|
|
796
|
+
export function likeActivity({ urls, noteId, serial, to = null }) {
|
|
779
797
|
return {
|
|
780
798
|
'@context': AS_CTX,
|
|
781
799
|
id: urls.actor + '#like-' + serial,
|
|
782
800
|
type: 'Like',
|
|
783
801
|
actor: urls.actor,
|
|
802
|
+
...(to ? { to: [to] } : {}),
|
|
784
803
|
object: noteId,
|
|
785
804
|
};
|
|
786
805
|
}
|
|
@@ -793,7 +812,7 @@ export function announceActivity({ urls, object, serial, published = new Date().
|
|
|
793
812
|
audience = null }) {
|
|
794
813
|
return {
|
|
795
814
|
'@context': AS_CTX,
|
|
796
|
-
id: urls
|
|
815
|
+
id: announceActivityId(urls, serial),
|
|
797
816
|
type: 'Announce',
|
|
798
817
|
actor: urls.actor,
|
|
799
818
|
published,
|
|
@@ -831,10 +850,20 @@ export function updateActorActivity({ urls, actor, serial, published = new Date(
|
|
|
831
850
|
// `serial` names the activity when the note is not being edited — a quote
|
|
832
851
|
// authorization arriving on it changes what the note says about its quote,
|
|
833
852
|
// not what it says.
|
|
853
|
+
// An edit's Update is named by when the edit was made, so whoever made it can
|
|
854
|
+
// say its id before it exists; a restatement that is not an edit has no stamp.
|
|
855
|
+
// Each is a document of its own beside the post, so it can be fetched by its
|
|
856
|
+
// id (§3.1) once it is on the public record; a fragment of another document
|
|
857
|
+
// could not be.
|
|
858
|
+
export const updateActivityId = (noteId, updated) => noteId + '-update-' + String(updated).replace(/[^0-9TZ]/g, '');
|
|
859
|
+
export const deleteActivityId = (noteId) => noteId + '-delete';
|
|
860
|
+
export const announceActivityId = (urls, serial) => urls.notes + 'announce-' + serial;
|
|
861
|
+
export const undoActivityId = (urls, serial) => urls.notes + 'undo-' + serial;
|
|
862
|
+
|
|
834
863
|
export function updateActivity(note, urls, { serial = null } = {}) {
|
|
835
864
|
return {
|
|
836
865
|
'@context': AS_CTX,
|
|
837
|
-
id: note.
|
|
866
|
+
id: note.updated ? updateActivityId(note.id, note.updated) : note.id + '-update-q' + (serial ?? Date.now()),
|
|
838
867
|
type: 'Update',
|
|
839
868
|
actor: urls.actor,
|
|
840
869
|
to: note.to, cc: note.cc,
|
|
@@ -842,23 +871,30 @@ export function updateActivity(note, urls, { serial = null } = {}) {
|
|
|
842
871
|
};
|
|
843
872
|
}
|
|
844
873
|
|
|
845
|
-
|
|
874
|
+
// Addressed as the post was, so a followers-only or direct post's deletion
|
|
875
|
+
// reaches the people who had it and is not announced to anyone else.
|
|
876
|
+
export function deleteActivity({ urls, noteId, to = [PUBLIC], cc = null }) {
|
|
846
877
|
return {
|
|
847
878
|
'@context': AS_CTX,
|
|
848
|
-
id: noteId
|
|
879
|
+
id: deleteActivityId(noteId),
|
|
849
880
|
type: 'Delete',
|
|
850
881
|
actor: urls.actor,
|
|
851
|
-
to
|
|
882
|
+
to,
|
|
883
|
+
...(cc?.length ? { cc } : {}),
|
|
852
884
|
object: { id: noteId, type: 'Tombstone' },
|
|
853
885
|
};
|
|
854
886
|
}
|
|
855
887
|
|
|
888
|
+
// Addressed as what it takes back was: an Undo goes to whoever had that.
|
|
856
889
|
export function undoActivity({ urls, activity, serial }) {
|
|
890
|
+
const to = activity?.to ?? (activity?.type === 'Follow' && typeof activity.object === 'string' ? [activity.object] : null);
|
|
857
891
|
return {
|
|
858
892
|
'@context': AS_CTX,
|
|
859
|
-
id: urls
|
|
893
|
+
id: undoActivityId(urls, serial),
|
|
860
894
|
type: 'Undo',
|
|
861
895
|
actor: urls.actor,
|
|
896
|
+
...(to ? { to } : {}),
|
|
897
|
+
...(activity?.cc ? { cc: activity.cc } : {}),
|
|
862
898
|
object: activity,
|
|
863
899
|
};
|
|
864
900
|
}
|
|
@@ -111,7 +111,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
111
111
|
// A Move target nobody can resolve is a landing pad nobody lands on.
|
|
112
112
|
if (!webfingerHost(urls.base) && !cfg.gateway?.frontActor) {
|
|
113
113
|
return json(res, 400, {
|
|
114
|
-
error: 'this pod is
|
|
114
|
+
error: 'this pod is suffixed, so WebFinger cannot answer for it '
|
|
115
115
|
+ '— other servers could never resolve this account as a Move target',
|
|
116
116
|
});
|
|
117
117
|
}
|
|
@@ -13,6 +13,7 @@ import { copyPrivateHalf, isCurrent, CURRENT_LAYOUT } from '../../migrate.mjs';
|
|
|
13
13
|
import { insecureUrlReason } from '../../../shared/safefetch.mjs';
|
|
14
14
|
import { newRun, preflight, runSetup, setupInputError, hasCredential, credentialPath } from '../../setup.mjs';
|
|
15
15
|
import { podLayout } from '../../../pod/root.mjs';
|
|
16
|
+
import { chosenRoot, publicIndexKnown } from '../../../core/place.mjs';
|
|
16
17
|
import { portFree, freePortFrom } from '../../ports.mjs';
|
|
17
18
|
import { yieldDirectory } from '../../../gateway/directory.mjs';
|
|
18
19
|
import { localFetch } from '../../../client/localapi.mjs';
|
|
@@ -115,7 +116,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
115
116
|
}
|
|
116
117
|
// ---- setup, driven by the page at /admin/setup/ ----
|
|
117
118
|
case '/setup/check': {
|
|
118
|
-
const pre = preflight(body);
|
|
119
|
+
const pre = { ...preflight(body), ...await placeCheck(body) };
|
|
119
120
|
// For a new pod, ask the provider where it puts pods, so the page can
|
|
120
121
|
// show the Gateway address before the pod exists. A provider on paths
|
|
121
122
|
// means the address must live at the Gateway; the run decides for real.
|
|
@@ -410,3 +411,18 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
|
|
|
410
411
|
default: return false;
|
|
411
412
|
}
|
|
412
413
|
}
|
|
414
|
+
|
|
415
|
+
// Where the data will go and whether the type index question has to be asked:
|
|
416
|
+
// a pod you bring is read as a stranger would (its profile is public); a new
|
|
417
|
+
// pod has no index, so the question is always asked for one.
|
|
418
|
+
async function placeCheck(body) {
|
|
419
|
+
if (body.mode === 'new') return { typeIndex: false };
|
|
420
|
+
let base;
|
|
421
|
+
try { base = new URL(String(body.pod || '')).href; } catch { return {}; }
|
|
422
|
+
if (!base.endsWith('/')) base += '/';
|
|
423
|
+
const place = chosenRoot(base, body.container);
|
|
424
|
+
return {
|
|
425
|
+
typeIndex: await publicIndexKnown(fetch, base),
|
|
426
|
+
...(place.problem ? { placeProblem: place.problem } : { place: base + place.root }),
|
|
427
|
+
};
|
|
428
|
+
}
|