fedipod 1.36.6 → 1.40.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 +29 -8
- package/architecture.md +8 -0
- package/bin/fedipod.mjs +6 -0
- package/cli.md +7 -1
- package/device-agent.md +3 -3
- package/gateway.md +69 -8
- package/groups.md +2 -1
- package/gui.md +3 -1
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/client/masto/bridge.mjs +61 -0
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/client/masto/statuses.mjs +3 -0
- package/lib/connections/acctfeed.mjs +13 -9
- package/lib/connections/bskyfeed.mjs +13 -9
- package/lib/connections/tagfeed.mjs +14 -9
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +226 -48
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/intake/verify.mjs +11 -0
- package/lib/core/lease.mjs +15 -1
- package/lib/core/place.mjs +82 -0
- package/lib/core/pod-only.mjs +4 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +25 -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/scheduled.mjs +39 -0
- package/lib/core/social.mjs +62 -32
- package/lib/core/storage.mjs +67 -0
- package/lib/core/store.mjs +77 -5
- 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/account-agent.mjs +111 -0
- package/lib/gateway/copy.mjs +381 -0
- package/lib/gateway/front-core.mjs +74 -63
- package/lib/gateway/gateway-core.mjs +107 -9
- package/lib/gateway/held-mail.mjs +198 -0
- package/lib/gateway/keeper-due.mjs +39 -0
- package/lib/gateway/keeper-session.mjs +10 -0
- package/lib/gateway/keeper.mjs +72 -0
- package/lib/gateway/masto-gateway.mjs +510 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/state-api.mjs +207 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +17 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +214 -24
- 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 +5 -5
- package/lib/session/demo.html +1 -1
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +2 -2
- package/package.json +2 -2
- package/run-agent.mjs +6 -16
- package/scripts/stage-site.mjs +17 -4
- package/web/admin/actors.js +2 -0
- package/web/admin/gateway.js +14 -1
- package/web/admin/index.html +13 -0
- package/web/admin/oauth-signin.mjs +1 -1
- 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 +2 -2
- package/web/app/admin-facade.mjs +12 -2
- package/web/app/agent.mjs +228 -51
- package/web/app/boot.mjs +75 -32
- package/web/app/copy-mode.mjs +223 -0
- package/web/app/dist/boot.js +498 -88
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +4016 -2539
- 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/sw-src.mjs +15 -53
- package/web/app/update.js +3 -2
- package/web/app/warm-start.mjs +115 -0
- package/web/app-signin/app-signin.mjs +78 -0
- package/web/app-signin/index.html +41 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// scheduled.mjs — what falls due while nobody is looking: scheduled posts
|
|
2
|
+
// whose time has come, and polls whose voting has ended. Any agent that is
|
|
3
|
+
// running when something falls due does it: the owner's app while it is open,
|
|
4
|
+
// the gateway's keeper while it is closed (lib/gateway/keeper.mjs), a
|
|
5
|
+
// DeviceAgent always.
|
|
6
|
+
//
|
|
7
|
+
// A scheduled post is taken off the list before it is published, so a slow
|
|
8
|
+
// publish cannot post twice; one that fails is dropped, with its reason in the
|
|
9
|
+
// log. A poll is shut and everyone holding it is told once
|
|
10
|
+
// (publisher/questions.mjs: closeDuePolls).
|
|
11
|
+
|
|
12
|
+
/** Publish what has fallen due. Returns how many scheduled posts went out. */
|
|
13
|
+
export async function publishDue(store, publisher, log = () => {}, now = Date.now()) {
|
|
14
|
+
const due = store.getScheduled().filter((e) => Date.parse(e.scheduledAt) <= now);
|
|
15
|
+
for (const e of due) {
|
|
16
|
+
store.setScheduled(store.getScheduled().filter((x) => x.id !== e.id));
|
|
17
|
+
await publisher.publishNote(e.params.status, {
|
|
18
|
+
inReplyTo: e.params.inReplyTo, attachments: e.params.attachments,
|
|
19
|
+
visibility: e.params.visibility, spoilerText: e.params.spoilerText,
|
|
20
|
+
}).then(() => log(`scheduled post published (${e.id})`))
|
|
21
|
+
.catch((err) => log(`scheduled post ${e.id} failed: ${err.message} — dropped`));
|
|
22
|
+
}
|
|
23
|
+
await publisher.closeDuePolls(now).catch((err) => log(`closing polls: ${err.message}`));
|
|
24
|
+
return due.length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* When the next thing falls due, or null when nothing is waiting: a scheduled
|
|
29
|
+
* post, a poll's end, or another try at a delivery that failed.
|
|
30
|
+
*/
|
|
31
|
+
export function nextDue(store) {
|
|
32
|
+
const at = [
|
|
33
|
+
...store.getScheduled().map((e) => Date.parse(e.scheduledAt)),
|
|
34
|
+
...(store.getStatuses?.() || []).filter((s) => s.kind === 'post' && s.poll && !s.poll.closed && s.poll.expiresAt)
|
|
35
|
+
.map((s) => Date.parse(s.poll.expiresAt)),
|
|
36
|
+
...(store.getQueue?.() || []).map((q) => Number(q.nextAt)),
|
|
37
|
+
].filter(Number.isFinite);
|
|
38
|
+
return at.length ? new Date(Math.min(...at)).toISOString() : null;
|
|
39
|
+
}
|
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/storage.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import fsp from 'node:fs/promises';
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
16
|
import * as $rdf from 'rdflib';
|
|
17
|
+
import { podOnly } from './pod-only.mjs';
|
|
17
18
|
|
|
18
19
|
const LDP = $rdf.Namespace('http://www.w3.org/ns/ldp#');
|
|
19
20
|
const slash = (u) => (u.endsWith('/') ? u : u + '/');
|
|
@@ -170,6 +171,72 @@ export class FileStorage {
|
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
export { podOnly };
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* An account's state kept at its gateway, reached through the gateway's state
|
|
178
|
+
* API (lib/gateway/state-api.mjs) with the token it handed out. `holder` is
|
|
179
|
+
* this agent's lease id: the gateway takes a write only from the lease's
|
|
180
|
+
* holder, and `onRefused` hears when it would not. What stays on the pod is
|
|
181
|
+
* read and written on `pod`.
|
|
182
|
+
*/
|
|
183
|
+
export class StateApiStorage {
|
|
184
|
+
constructor(base, { fetchImpl = globalThis.fetch, token, holder, pod = null, onRefused = null }) {
|
|
185
|
+
this.base = slash(base);
|
|
186
|
+
this.fetchImpl = fetchImpl;
|
|
187
|
+
this.token = token;
|
|
188
|
+
this.holder = holder;
|
|
189
|
+
this.pod = pod;
|
|
190
|
+
this.onRefused = onRefused;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
get kind() { return 'copy'; }
|
|
194
|
+
|
|
195
|
+
_ask(p, init = {}) {
|
|
196
|
+
return this.fetchImpl(this.base + encodeURIComponent(p), {
|
|
197
|
+
...init, headers: { authorization: `Bearer ${this.token}`, ...(init.headers || {}) },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async list(sub = '', { etag } = {}) {
|
|
202
|
+
if (sub) return { notModified: false, names: [], etag: null };
|
|
203
|
+
const res = await this._ask('', { headers: etag ? { 'if-none-match': etag } : {} });
|
|
204
|
+
if (res.status === 304) return { notModified: true, names: null, etag };
|
|
205
|
+
if (res.status >= 400) throw new Error(`the account's copy at the gateway is unreadable (HTTP ${res.status})`);
|
|
206
|
+
const { names } = await res.json();
|
|
207
|
+
return { notModified: false, names, etag: res.headers.get('etag') };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async read(p, opts = {}) {
|
|
211
|
+
if (podOnly(p)) return this.pod ? this.pod.read(p, opts) : { ok: false, notModified: false, status: 404, body: null, etag: null };
|
|
212
|
+
const res = await this._ask(p, { headers: opts.etag ? { 'if-none-match': opts.etag } : {} });
|
|
213
|
+
if (res.status === 304) return { ok: true, notModified: true, status: 304, body: null, etag: opts.etag };
|
|
214
|
+
if (res.status >= 400) return { ok: false, notModified: false, status: res.status, body: null, etag: null };
|
|
215
|
+
return { ok: true, notModified: false, status: res.status, body: await res.text(), etag: res.headers.get('etag') };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async write(p, body, contentType = 'application/json') {
|
|
219
|
+
if (podOnly(p)) return this.pod ? this.pod.write(p, body, contentType) : { ok: false, retry: false, why: 'no pod for this document' };
|
|
220
|
+
try {
|
|
221
|
+
const res = await this._ask(p, { method: 'PUT', headers: { 'content-type': contentType, 'x-fedipod-holder': this.holder }, body });
|
|
222
|
+
if (res.status < 400) return { ok: true, retry: false, why: '' };
|
|
223
|
+
if (res.status === 409) { this.onRefused?.(); return { ok: false, retry: false, why: 'another agent holds this account now', lost: true }; }
|
|
224
|
+
return { ok: false, retry: res.status >= 500 || res.status === 429, why: `HTTP ${res.status}`, retryAfterMs: 0 };
|
|
225
|
+
} catch (e) {
|
|
226
|
+
return { ok: false, retry: true, why: e.message, retryAfterMs: 0 };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async remove(p) {
|
|
231
|
+
if (podOnly(p)) return this.pod ? this.pod.remove(p) : false;
|
|
232
|
+
try {
|
|
233
|
+
const res = await this._ask(p, { method: 'DELETE', headers: { 'x-fedipod-holder': this.holder } });
|
|
234
|
+
if (res.status === 409) { this.onRefused?.(); return false; }
|
|
235
|
+
return res.status < 400 || res.status === 404;
|
|
236
|
+
} catch { return false; }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
173
240
|
// `file:` (or a bare path) is a directory; anything else is a pod.
|
|
174
241
|
export function storageFor(base, fetchImpl) {
|
|
175
242
|
return /^https?:/i.test(base) ? new HttpStorage(base, fetchImpl) : new FileStorage(base);
|
package/lib/core/store.mjs
CHANGED
|
@@ -90,6 +90,15 @@ export class PodStore {
|
|
|
90
90
|
this._held = 0;
|
|
91
91
|
this.chain = Promise.resolve(); // serialized writes
|
|
92
92
|
this.verdicts = new Map(); // name → did its last write land? (read by commit)
|
|
93
|
+
// Moves on each time queued writes are dropped (discardPending): work that
|
|
94
|
+
// began in an earlier generation has lost results, whatever a commit says.
|
|
95
|
+
this.generation = 0;
|
|
96
|
+
// Told what the pod now holds: after a load, before and after each document
|
|
97
|
+
// that is written or removed. A browser keeps its copy between worker
|
|
98
|
+
// restarts with these; onWriting is awaited, so it is marked before it goes.
|
|
99
|
+
this.onLoaded = null; // () => void
|
|
100
|
+
this.onWriting = null; // (name, text | null) => Promise
|
|
101
|
+
this.onSettled = null; // (name) => void
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
get base() { return this.storage?.base || null; }
|
|
@@ -158,6 +167,36 @@ export class PodStore {
|
|
|
158
167
|
this.lastSkipped = skipped;
|
|
159
168
|
if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`);
|
|
160
169
|
this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
|
|
170
|
+
this.onLoaded?.();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Take up a copy kept from an earlier load, as the pod held it: each
|
|
174
|
+
// document's bytes and ETag, and the container's ETag. The next load() then
|
|
175
|
+
// revalidates instead of downloading.
|
|
176
|
+
restore({ docs, listingEtag }) {
|
|
177
|
+
for (const { name, text, etag } of docs) {
|
|
178
|
+
try { this.cache.set(name, JSON.parse(text)); } catch { continue; }
|
|
179
|
+
this.lastText.set(name, text);
|
|
180
|
+
if (etag) this.etags.set(name, etag);
|
|
181
|
+
}
|
|
182
|
+
if (listingEtag) this.etags.set('', listingEtag);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Read these documents again, whatever is held: a kept copy caught them
|
|
186
|
+
// mid-write, so the pod's copy is the one to hold. Throws when one cannot be
|
|
187
|
+
// read, so the caller can fall back to a full load rather than act without it.
|
|
188
|
+
async refresh(names) {
|
|
189
|
+
for (const name of names) {
|
|
190
|
+
const r = await this.storage.read(name);
|
|
191
|
+
if (r.status === 404) {
|
|
192
|
+
this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name);
|
|
193
|
+
} else if (!r.ok) {
|
|
194
|
+
throw new Error(`state doc ${name} unreadable (HTTP ${r.status})`);
|
|
195
|
+
} else {
|
|
196
|
+
this.cache.set(name, JSON.parse(r.body)); this.lastText.set(name, r.body); this.etags.set(name, r.etag);
|
|
197
|
+
}
|
|
198
|
+
this.onSettled?.(name);
|
|
199
|
+
}
|
|
161
200
|
}
|
|
162
201
|
|
|
163
202
|
has(name) { return this.cache.has(name); }
|
|
@@ -221,12 +260,13 @@ export class PodStore {
|
|
|
221
260
|
_put(name) {
|
|
222
261
|
const done = this.chain.then(async () => {
|
|
223
262
|
const body = serialise(this.cache.get(name));
|
|
263
|
+
await Promise.resolve(this.onWriting?.(name, body)).catch(() => {});
|
|
224
264
|
for (let attempt = 1; attempt <= PUT_RETRIES; attempt++) {
|
|
225
265
|
// A storage that throws is a bug, not a hiccup — but it must not
|
|
226
266
|
// escape into the write queue, where it would look like success.
|
|
227
267
|
const r = await this.storage.write(name, body, 'application/json')
|
|
228
268
|
.catch(e => ({ ok: false, retry: false, why: e.message }));
|
|
229
|
-
if (r.ok) { this.lastText.set(name, body); return true; }
|
|
269
|
+
if (r.ok) { this.lastText.set(name, body); this.onSettled?.(name); return true; }
|
|
230
270
|
// The storage says whether trying again could possibly help: a pod's
|
|
231
271
|
// 4xx is an answer rather than a hiccup, and a directory's EACCES will
|
|
232
272
|
// still be an EACCES in two seconds.
|
|
@@ -255,7 +295,10 @@ export class PodStore {
|
|
|
255
295
|
clearTimeout(this.timers.get(name));
|
|
256
296
|
this.timers.delete(name);
|
|
257
297
|
if (!this.storage) return true;
|
|
258
|
-
|
|
298
|
+
await Promise.resolve(this.onWriting?.(name, null)).catch(() => {});
|
|
299
|
+
const gone = await this.storage.remove(name);
|
|
300
|
+
this.onSettled?.(name);
|
|
301
|
+
return gone;
|
|
259
302
|
}
|
|
260
303
|
|
|
261
304
|
// Force every pending write out NOW and say whether they all landed.
|
|
@@ -263,7 +306,9 @@ export class PodStore {
|
|
|
263
306
|
// pod's inbox is a destructive read, so it must not happen until the result
|
|
264
307
|
// of handling it is written down. With no storage the store is pure memory
|
|
265
308
|
// and there is nothing to land, so that counts as written.
|
|
266
|
-
|
|
309
|
+
// `since`: the generation the caller's work began in. Anything dropped since
|
|
310
|
+
// makes the answer false.
|
|
311
|
+
async commit({ since } = {}) {
|
|
267
312
|
const pending = [];
|
|
268
313
|
// Held names too, or a sweep that suspended the debounce would delete the
|
|
269
314
|
// pod's copy of an item whose result had not been written down — the one
|
|
@@ -279,7 +324,7 @@ export class PodStore {
|
|
|
279
324
|
// Every verdict since the last commit, forced and self-fired alike. Cleared
|
|
280
325
|
// once read: a refusal is reported to the caller that can act on it, and
|
|
281
326
|
// does not then condemn every later commit.
|
|
282
|
-
const landed = [...this.verdicts.values()].every(Boolean);
|
|
327
|
+
const landed = [...this.verdicts.values()].every(Boolean) && (since === undefined || since === this.generation);
|
|
283
328
|
this.verdicts.clear();
|
|
284
329
|
return landed;
|
|
285
330
|
}
|
|
@@ -287,6 +332,23 @@ export class PodStore {
|
|
|
287
332
|
// Flush pending debounced writes (shutdown path). Same work, result ignored.
|
|
288
333
|
async flush() { await this.commit(); }
|
|
289
334
|
|
|
335
|
+
// Forget the writes not yet made, and wait out any in flight. For an agent
|
|
336
|
+
// whose state another agent has written since those writes were queued (a
|
|
337
|
+
// copy's lease coming back after an app acted, web/app/copy-mode.mjs): the
|
|
338
|
+
// next load reads what the other agent wrote, and sending these first would
|
|
339
|
+
// write over it.
|
|
340
|
+
// The generation moves on first, before anything is awaited, so a sweep that
|
|
341
|
+
// began before — the inbox drain above all — finds at its next commit that
|
|
342
|
+
// its results were not all written down, and deletes nothing on the strength
|
|
343
|
+
// of them. Work that begins after is not affected.
|
|
344
|
+
async discardPending() {
|
|
345
|
+
this.generation += 1;
|
|
346
|
+
for (const t of this.timers.values()) clearTimeout(t);
|
|
347
|
+
this.timers.clear();
|
|
348
|
+
this.dirty.clear();
|
|
349
|
+
await this.chain.catch(() => {});
|
|
350
|
+
}
|
|
351
|
+
|
|
290
352
|
// ---- the domain helpers, unchanged from dk's Store ----
|
|
291
353
|
|
|
292
354
|
// config: { remotePod, handle, name, issuer } (credential lives ONLY in
|
|
@@ -305,7 +367,17 @@ export class PodStore {
|
|
|
305
367
|
const b = this.read('blocklist.json', {});
|
|
306
368
|
return { domains: b.domains || [], actors: b.actors || [] };
|
|
307
369
|
}
|
|
308
|
-
|
|
370
|
+
// Blocking someone ends their following as well: a blocked actor, or anyone
|
|
371
|
+
// on a blocked domain, is no longer delivered to. The mark dropFollower leaves
|
|
372
|
+
// keeps a reconcile from bringing them back.
|
|
373
|
+
setBlocklist(b) {
|
|
374
|
+
this.write('blocklist.json', b);
|
|
375
|
+
const c = this.getContacts();
|
|
376
|
+
const gone = c.followers.filter(f => f.actor && this.isBlocked(f.actor)).map(f => f.actor);
|
|
377
|
+
if (!gone.length) return;
|
|
378
|
+
for (const actor of gone) dropFollower(c, actor, 'blocked');
|
|
379
|
+
this.setContacts(c);
|
|
380
|
+
}
|
|
309
381
|
// Takes an actor URL or an object URL: the actor list only ever matches the
|
|
310
382
|
// former, the domain list matches either.
|
|
311
383
|
isBlocked(url) {
|