fedipod-server 0.28.1 → 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/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 +33 -2
- package/lib/core/intake/notes.mjs +12 -5
- 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 +11 -1
- 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/front-core.mjs +54 -58
- package/lib/gateway/gateway-core.mjs +87 -8
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +171 -22
- 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/fedi-account.mjs +19 -10
- package/lib/session/package.json +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +2 -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 +11 -2
- package/web/app/boot.mjs +72 -32
- package/web/app/dist/boot.js +430 -86
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +3294 -2648
- 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/front/run.html +7 -1
- package/web/front/run.js +30 -4
|
@@ -28,6 +28,8 @@ import { ALL_COLLECTIONS } from './collections.mjs';
|
|
|
28
28
|
import * as restore from './restore.mjs';
|
|
29
29
|
import * as notes from './notes.mjs';
|
|
30
30
|
import * as questions from './questions.mjs';
|
|
31
|
+
import * as own from './own.mjs';
|
|
32
|
+
import { recordPlace } from '../place.mjs';
|
|
31
33
|
|
|
32
34
|
const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
33
35
|
path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../package.json'), 'utf8')).version;
|
|
@@ -40,6 +42,8 @@ export class Publisher {
|
|
|
40
42
|
this.remote = remote;
|
|
41
43
|
this.store = store;
|
|
42
44
|
this.deliverer = deliverer;
|
|
45
|
+
// Everything this actor sends goes on the outbox its owner reads.
|
|
46
|
+
if (deliverer) deliverer.onSent = (a) => own.recordOwn(this, a);
|
|
43
47
|
this.publicKeyPem = publicKeyPem;
|
|
44
48
|
this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
|
|
45
49
|
// Where this identity's client surface answers, when that is an address a
|
|
@@ -111,6 +115,9 @@ export class Publisher {
|
|
|
111
115
|
postingRestrictedToMods: this.config.kind === 'group' ? !!this.config.postingRestrictedToMods : null,
|
|
112
116
|
moderators,
|
|
113
117
|
pendingFollowers: priv ? urls.pendingFollowers : null,
|
|
118
|
+
// Named only where the private folder is proved to keep it private.
|
|
119
|
+
liked: priv ? urls.liked : null,
|
|
120
|
+
ownerOutbox: priv ? urls.ownOutbox : null,
|
|
114
121
|
pendingFollowing: priv ? urls.pendingFollowing : null,
|
|
115
122
|
blocked: priv ? urls.blocked : null,
|
|
116
123
|
inbox,
|
|
@@ -244,6 +251,13 @@ export class Publisher {
|
|
|
244
251
|
} catch (e) {
|
|
245
252
|
this.log(`WebID profile not updated with the actor link: ${e.message}`);
|
|
246
253
|
}
|
|
254
|
+
// Where the account lives, in the owner's public type index — only where
|
|
255
|
+
// they already have one; an index is made only on their yes, at sign-up.
|
|
256
|
+
if (!this.config.forum) {
|
|
257
|
+
await recordPlace(this.remote, urls.base, urls.home + 'ap/actor')
|
|
258
|
+
.then((r) => { if (r === 'registered') this.log('the public type index now records where the account lives'); })
|
|
259
|
+
.catch((e) => this.log(`type index not updated with the account's place: ${e.message}`));
|
|
260
|
+
}
|
|
247
261
|
|
|
248
262
|
// inbox: public may only Append; owner (the agent) reads + drains. A
|
|
249
263
|
// quiesced actor keeps its name resolving but takes no more mail, so a
|
|
@@ -277,7 +291,7 @@ export class Publisher {
|
|
|
277
291
|
this.log(this.config.gateway?.frontActor || wire.webfingerHost(urls.base)
|
|
278
292
|
? `profile published: @${pubName}@${pubHost} → ${urls.actor}`
|
|
279
293
|
: `profile published → ${urls.actor} — NOT discoverable as @${pubName}@${pubHost}: `
|
|
280
|
-
+ 'this pod is
|
|
294
|
+
+ 'this pod is suffixed, and WebFinger is only answered for a subdomained pod');
|
|
281
295
|
return { unreachable, updated };
|
|
282
296
|
}
|
|
283
297
|
|
|
@@ -468,6 +482,14 @@ export class Publisher {
|
|
|
468
482
|
publishBlocked(...a) { return collections.publishBlocked(this, ...a); }
|
|
469
483
|
recordOutbox(...a) { return collections.recordOutbox(this, ...a); }
|
|
470
484
|
unrecordOutbox(...a) { return collections.unrecordOutbox(this, ...a); }
|
|
485
|
+
recordOwn(...a) { return own.recordOwn(this, ...a); }
|
|
486
|
+
backfillLiked() { return own.backfillLiked(this); }
|
|
487
|
+
inboxesFor(...a) { return notes.inboxesFor(this, ...a); }
|
|
488
|
+
noteToSelf(...a) { return notes.noteToSelf(this, ...a); }
|
|
489
|
+
updateObject(...a) { return notes.updateObject(this, ...a); }
|
|
490
|
+
unrecordOwn(...a) { return own.unrecordOwn(this, ...a); }
|
|
491
|
+
publishOwn(...a) { return own.publishOwn(this, ...a); }
|
|
492
|
+
ownSettled() { return own.ownSettled(this); }
|
|
471
493
|
publishFeatured(...a) { return collections.publishFeatured(this, ...a); }
|
|
472
494
|
|
|
473
495
|
// restore.mjs
|
|
@@ -79,7 +79,9 @@ export async function mentionsFor(publisher, content, inReplyTo) {
|
|
|
79
79
|
const doc = await publisher.resolveMention(handle).catch(() => null);
|
|
80
80
|
if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
|
|
81
81
|
if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
|
|
82
|
-
|
|
82
|
+
// Naming yourself still tags you; it never delivers to your own inbox (§7.1).
|
|
83
|
+
const self = doc.id === publisher.urls.actor;
|
|
84
|
+
mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: self ? null : doc.endpoints?.sharedInbox || doc.inbox });
|
|
83
85
|
}
|
|
84
86
|
return mentions;
|
|
85
87
|
}
|
|
@@ -144,12 +146,62 @@ export function assertDirectAddressed(content, mentions) {
|
|
|
144
146
|
// otherwise the agent mints one as it always has.
|
|
145
147
|
const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
146
148
|
export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
|
|
147
|
-
async function slugFor(publisher, container, wanted, published) {
|
|
149
|
+
export async function slugFor(publisher, container, wanted, published) {
|
|
148
150
|
const name = safeSlug(wanted);
|
|
149
151
|
if (name && !(await podNotes.read(publisher.remote, container + name).catch(() => null))) return name;
|
|
150
152
|
return published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
// A message from this account to itself, for something its owner must hear
|
|
156
|
+
// and has no other way to: a post an app sent through the outbox door that the
|
|
157
|
+
// account then refused, after the door had already told the app "created".
|
|
158
|
+
// Addressed to the owner alone and kept in the private container, so it shows
|
|
159
|
+
// in the owner's apps as a direct message and a mention, and nowhere else.
|
|
160
|
+
export async function noteToSelf(publisher, text) {
|
|
161
|
+
const { urls } = publisher;
|
|
162
|
+
const published = new Date().toISOString();
|
|
163
|
+
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
164
|
+
const id = urls.privateNotes + slug;
|
|
165
|
+
const content = wire.contentHtml(text);
|
|
166
|
+
await podNotes.write(publisher.remote, id, { '@context': wire.AS_CTX, id, type: 'Note',
|
|
167
|
+
attributedTo: urls.actor, to: [urls.actor], published, content })
|
|
168
|
+
.catch(e => publisher.log(`note to self kept here only — the pod would not take it: ${e.message}`));
|
|
169
|
+
publisher.store.addStatus({ noteId: id, actor: urls.actor, content, text, published,
|
|
170
|
+
kind: 'post', visibility: 'direct', slug });
|
|
171
|
+
publisher.store.addNotification({ type: 'mention', actor: urls.actor, noteId: id });
|
|
172
|
+
return id;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// An app's edit of an object that is not a Note — an annotation, a poll —
|
|
176
|
+
// changes only the fields it sends (§6.3); a field sent as null is removed.
|
|
177
|
+
// What the object is, who made it and when, where it lives and who it is
|
|
178
|
+
// addressed to stay as they are, and so do a poll's choices and its count.
|
|
179
|
+
const FIXED = new Set(['@context', 'id', 'type', 'attributedTo', 'published', 'to', 'cc', 'bto', 'bcc',
|
|
180
|
+
'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies']);
|
|
181
|
+
export async function updateObject(publisher, s, patch, { updated = new Date().toISOString() } = {}) {
|
|
182
|
+
const { urls } = publisher;
|
|
183
|
+
const doc = await podNotes.read(publisher.remote, s.noteId);
|
|
184
|
+
if (!doc?.id) throw new Error('the object is not on the pod to change');
|
|
185
|
+
const next = { ...doc };
|
|
186
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
187
|
+
if (FIXED.has(k)) continue;
|
|
188
|
+
if (v === null) delete next[k]; else next[k] = v;
|
|
189
|
+
}
|
|
190
|
+
if (typeof next.content === 'string') next.content = wire.sanitizeHtml(next.content);
|
|
191
|
+
next.updated = updated;
|
|
192
|
+
await podNotes.write(publisher.remote, doc.id, next);
|
|
193
|
+
await podNotes.writeCreate(publisher.remote, wire.createActivityId(doc.id), wire.createActivity(next, urls));
|
|
194
|
+
publisher.store.updateStatus(s.noteId, { content: rowContent(next), editedAt: updated });
|
|
195
|
+
const update = wire.updateActivity(next, urls);
|
|
196
|
+
const inboxes = [...new Set([
|
|
197
|
+
...(s.visibility === 'direct' ? [] : publisher.store.getContacts().followers.map(f => f.sharedInbox || f.inbox)),
|
|
198
|
+
...(await inboxesFor(publisher, [...(s.addressed || []), ...(s.replyActor ? [s.replyActor] : [])])),
|
|
199
|
+
].filter(Boolean))];
|
|
200
|
+
await publisher.deliverer.deliverToAll(inboxes, update);
|
|
201
|
+
if (publisher.store.read('outbox.json', []).includes(s.noteId)) await publisher.recordOutbox(update);
|
|
202
|
+
return update;
|
|
203
|
+
}
|
|
204
|
+
|
|
153
205
|
// What the author's own timeline shows for an object: its content, else its
|
|
154
206
|
// text under one of the names other vocabularies use, else a link to it.
|
|
155
207
|
export function rowContent(obj) {
|
|
@@ -173,7 +225,9 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
173
225
|
const published = new Date().toISOString();
|
|
174
226
|
const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
|
|
175
227
|
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
176
|
-
|
|
228
|
+
// Addressed by id (a client-to-server post) it is addressed already; only a
|
|
229
|
+
// post that names nobody but in its text has to name them there.
|
|
230
|
+
if (visibility === 'direct' && !also.length && !deliverTo.length) assertDirectAddressed(content, mentions);
|
|
177
231
|
const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
|
|
178
232
|
const selfQuote = quote?.id && quote.actor === urls.actor;
|
|
179
233
|
const quoted = quote?.id ? {
|
|
@@ -199,6 +253,11 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
199
253
|
publisher.store.addStatus({
|
|
200
254
|
noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
|
|
201
255
|
kind: 'post', slug, text: content, visibility,
|
|
256
|
+
// Everyone a client named, blind copies included: an edit or a deletion
|
|
257
|
+
// must reach them too. Kept here only, never published. `named` is the
|
|
258
|
+
// visible part, which an edit addresses again.
|
|
259
|
+
...(also.length || deliverTo.length ? { addressed: [...new Set([...also, ...deliverTo])] } : {}),
|
|
260
|
+
...(also.length ? { named: [...also] } : {}),
|
|
202
261
|
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
203
262
|
...(note.sensitive ? { sensitive: true } : {}),
|
|
204
263
|
...(attachments?.length ? { attachments } : {}),
|
|
@@ -301,6 +360,8 @@ export async function publishObject(publisher, object, { visibility = 'public',
|
|
|
301
360
|
publisher.store.addStatus({
|
|
302
361
|
noteId: id, actor: urls.actor, content: rowContent(row), published: row.published || published,
|
|
303
362
|
kind: 'post', slug: name, visibility,
|
|
363
|
+
...(also.length || deliverTo.length ? { addressed: [...new Set([...also, ...deliverTo])] } : {}),
|
|
364
|
+
...(also.length ? { named: [...also] } : {}),
|
|
304
365
|
});
|
|
305
366
|
if (await publisher.store.commit?.() === false) publisher.log(`object published but its timeline row was refused: ${id}`);
|
|
306
367
|
|
|
@@ -332,16 +393,16 @@ export function rowTags(note) {
|
|
|
332
393
|
return { ...(mentions.length ? { mentions } : {}), ...(hashtags.length ? { tags: hashtags } : {}) };
|
|
333
394
|
}
|
|
334
395
|
|
|
335
|
-
export async function updateNote(publisher, s, { content, spoilerText = null, sensitive = null, attachments = null } = {}) {
|
|
396
|
+
export async function updateNote(publisher, s, { content, spoilerText = null, sensitive = null, attachments = null, updated = new Date().toISOString() } = {}) {
|
|
336
397
|
const { urls } = publisher;
|
|
337
|
-
const updated = new Date().toISOString();
|
|
338
398
|
const inText = new Set(wire.mentionsIn(content));
|
|
339
399
|
const mentions = [];
|
|
340
400
|
for (const handle of inText) {
|
|
341
401
|
if (!publisher.resolveMention) break;
|
|
342
402
|
const doc = await publisher.resolveMention(handle).catch(() => null);
|
|
343
403
|
if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
|
|
344
|
-
mentions.push({ handle, actor: doc.id, page: doc.url || null,
|
|
404
|
+
mentions.push({ handle, actor: doc.id, page: doc.url || null,
|
|
405
|
+
inbox: doc.id === urls.actor ? null : doc.endpoints?.sharedInbox || doc.inbox });
|
|
345
406
|
}
|
|
346
407
|
const atts = attachments ?? s.attachments ?? [];
|
|
347
408
|
// The note stays in the container its visibility put it in; a recovered
|
|
@@ -353,7 +414,7 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
|
|
|
353
414
|
urls, slug, content, published: s.published, inReplyTo: s.inReplyTo,
|
|
354
415
|
attachments: atts, mentions, visibility: s.visibility || 'public',
|
|
355
416
|
summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
|
|
356
|
-
also: reply ? [reply.actor] : [],
|
|
417
|
+
also: [...new Set([...(s.named || []), ...(reply ? [reply.actor] : [])])],
|
|
357
418
|
quote: rowQuote(s),
|
|
358
419
|
});
|
|
359
420
|
await podNotes.write(publisher.remote, note.id, note);
|
|
@@ -372,8 +433,13 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
|
|
|
372
433
|
...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
373
434
|
...mentions.map(m => m.inbox),
|
|
374
435
|
reply?.inbox,
|
|
436
|
+
// everyone a client named or blind-copied, and a quoted author
|
|
437
|
+
...(await inboxesFor(publisher, [...(s.addressed || []), ...(s.quoteRequest?.actor ? [s.quoteRequest.actor] : [])])),
|
|
375
438
|
].filter(Boolean))];
|
|
376
439
|
await publisher.deliverer.deliverToAll(inboxes, update);
|
|
440
|
+
// The edit goes on the record beside the Create, for a post the outbox lists:
|
|
441
|
+
// a reader catching up from the outbox otherwise keeps the words it first saw.
|
|
442
|
+
if (publisher.store.read('outbox.json', []).includes(s.noteId)) await publisher.recordOutbox(update);
|
|
377
443
|
publisher.log(`note edited: ${note.id} → ${inboxes.length} inbox(es)`);
|
|
378
444
|
return patched;
|
|
379
445
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// own.mjs — the outbox as its owner reads it, and the owner's liked list.
|
|
2
|
+
//
|
|
3
|
+
// The public outbox is what a stranger may see: posts and boosts, and what
|
|
4
|
+
// withdrew them. ActivityPub's outbox is every message the actor produced,
|
|
5
|
+
// filtered by who is asking (§5.1), so its owner is owed the rest as well —
|
|
6
|
+
// likes, follows, undos, follower decisions, followers-only and direct posts.
|
|
7
|
+
// That view lives in the private container, whose owner-only rule it inherits,
|
|
8
|
+
// and is what the outbox address answers its signed-in owner.
|
|
9
|
+
//
|
|
10
|
+
// Everything this actor sends passes through the deliverer, which hands each
|
|
11
|
+
// fresh activity here (Deliverer.onSent), so nothing has to remember to record
|
|
12
|
+
// itself. What is never sent — a block or a pin posted by a client — is
|
|
13
|
+
// recorded by the client-to-server dispatcher.
|
|
14
|
+
//
|
|
15
|
+
// `liked` (§5.5) is the objects this actor has liked and not taken back,
|
|
16
|
+
// kept beside it under the same rule.
|
|
17
|
+
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import * as wire from '../wire.mjs';
|
|
20
|
+
import * as collection from '../../pod/collection.mjs';
|
|
21
|
+
|
|
22
|
+
const OWN = 'outbox-own.json';
|
|
23
|
+
const LIKED = 'liked.json';
|
|
24
|
+
// The owner's view keeps its newest entries; older ones leave the view only —
|
|
25
|
+
// the public outbox, and everything the account did, are untouched.
|
|
26
|
+
export const OWN_MAX = 5000;
|
|
27
|
+
|
|
28
|
+
// A Create is kept by its object's id, as the public outbox keeps it, so the
|
|
29
|
+
// two can be matched when a post is withdrawn; anything else is kept whole.
|
|
30
|
+
const localForm = (a) => (a?.type === 'Create' && (typeof a.object === 'string' ? a.object : a.object?.id)) || a;
|
|
31
|
+
|
|
32
|
+
export function recordOwn(publisher, activity) {
|
|
33
|
+
const { urls, store } = publisher;
|
|
34
|
+
if (!activity?.type || activity.actor !== urls.actor) return false;
|
|
35
|
+
const item = localForm(activity);
|
|
36
|
+
const id = wire.outboxItemId(item);
|
|
37
|
+
const own = store.read(OWN, []);
|
|
38
|
+
if (id && own.some(i => wire.outboxItemId(i) === id)) return false; // a retry, or said twice
|
|
39
|
+
own.unshift(item);
|
|
40
|
+
store.write(OWN, own.slice(0, OWN_MAX));
|
|
41
|
+
if (activity.type === 'Like' || (activity.type === 'Undo' && activity.object?.type === 'Like')) {
|
|
42
|
+
backfillLiked(publisher);
|
|
43
|
+
const object = wire.outboxItemId(activity.type === 'Like' ? activity.object : activity.object.object);
|
|
44
|
+
if (object) {
|
|
45
|
+
const liked = store.read(LIKED, []).filter(o => o !== object);
|
|
46
|
+
if (activity.type === 'Like') liked.unshift(object);
|
|
47
|
+
store.write(LIKED, liked);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
schedule(publisher);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// An account that liked things before the list existed: the likes it still
|
|
55
|
+
// holds (a timeline row marked favourited, with the Like that was sent) are
|
|
56
|
+
// its liked list to begin with. Once only — a list that exists, even empty,
|
|
57
|
+
// is the record from then on.
|
|
58
|
+
export function backfillLiked(publisher) {
|
|
59
|
+
const { store } = publisher;
|
|
60
|
+
if (store.read(LIKED, null) !== null) return 0;
|
|
61
|
+
const liked = (store.getStatuses?.() || [])
|
|
62
|
+
.filter(s => s.favourited && s.noteId)
|
|
63
|
+
.sort((x, y) => String(y.published || '').localeCompare(String(x.published || '')))
|
|
64
|
+
.map(s => s.noteId);
|
|
65
|
+
store.write(LIKED, [...new Set(liked)]);
|
|
66
|
+
schedule(publisher);
|
|
67
|
+
return liked.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A post deleted or a boost withdrawn leaves the owner's view as it leaves the
|
|
71
|
+
// public one; the Delete or Undo that did it is already recorded, having been
|
|
72
|
+
// sent.
|
|
73
|
+
export function unrecordOwn(publisher, matches) {
|
|
74
|
+
const own = publisher.store.read(OWN, []);
|
|
75
|
+
const kept = own.filter(i => !matches(i));
|
|
76
|
+
if (kept.length === own.length) return;
|
|
77
|
+
publisher.store.write(OWN, kept);
|
|
78
|
+
schedule(publisher);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Recording never waits on the pod: a like is answered when it is sent, and
|
|
82
|
+
// the pages follow. One publish at a time, the last one covering every record
|
|
83
|
+
// made while it waited.
|
|
84
|
+
function schedule(publisher) {
|
|
85
|
+
if (publisher._ownQueued) return;
|
|
86
|
+
publisher._ownQueued = true;
|
|
87
|
+
publisher._ownChain = (publisher._ownChain || Promise.resolve()).then(async () => {
|
|
88
|
+
publisher._ownQueued = false;
|
|
89
|
+
try { await publishOwn(publisher); }
|
|
90
|
+
catch (e) { publisher.log?.(`owner's outbox view not published: ${e.message}`); }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ownSettled(publisher) { return publisher._ownChain || Promise.resolve(); }
|
|
95
|
+
|
|
96
|
+
export async function publishOwn(publisher) {
|
|
97
|
+
const { urls, store, remote } = publisher;
|
|
98
|
+
// Written only where the private folder is proved to keep it private — the
|
|
99
|
+
// same bar the pending and blocked lists clear. Recorded meanwhile, and
|
|
100
|
+
// published the first time the bar is met.
|
|
101
|
+
if (publisher.privateReady && await publisher.privateReady() !== true) return;
|
|
102
|
+
const seen = store.read('published.json', {});
|
|
103
|
+
const own = store.read(OWN, []);
|
|
104
|
+
const out = wire.outboxPaging(own, seen.ownIndex || []);
|
|
105
|
+
const ownPages = await writePages(remote, seen.ownPages, out.pages.map((items, i) => ({
|
|
106
|
+
id: wire.outboxPageId(urls.ownOutbox, i + 1), doc: wire.outboxPage(urls.ownOutbox, i + 1, items),
|
|
107
|
+
})));
|
|
108
|
+
const ownHead = await writeHead(remote, seen.ownHead, urls.ownOutbox, wire.outboxHead(urls.ownOutbox, own.length, out.pages.length));
|
|
109
|
+
|
|
110
|
+
const liked = store.read(LIKED, []);
|
|
111
|
+
// liked.json is newest first; pages are filled from the oldest end.
|
|
112
|
+
const lk = wire.followersPaging([...liked].reverse(), seen.likedIndex || []);
|
|
113
|
+
const likedPages = await writePages(remote, seen.likedPages, lk.pages.map((items, i) => ({
|
|
114
|
+
id: wire.followersPageId(urls.liked, i + 1), doc: wire.followersPage(urls.liked, i + 1, items, lk.pages.length),
|
|
115
|
+
})));
|
|
116
|
+
const likedHead = await writeHead(remote, seen.likedHead, urls.liked, wire.followersHead(urls.liked, liked.length, lk.pages.length));
|
|
117
|
+
|
|
118
|
+
store.write('published.json', { ...store.read('published.json', {}),
|
|
119
|
+
ownPages, ownIndex: out.index, ownHead, likedPages, likedIndex: lk.index, likedHead });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A head is written only when it changed: most records change a page, not the
|
|
123
|
+
// count or the bounds.
|
|
124
|
+
async function writeHead(remote, before, url, doc) {
|
|
125
|
+
const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
|
|
126
|
+
if (digest !== before) await collection.writeHead(remote, url, doc);
|
|
127
|
+
return digest;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Only the pages that changed, and none past the new end. No rule is written:
|
|
131
|
+
// the private container's owner-only rule is inherited.
|
|
132
|
+
async function writePages(remote, before = {}, pages) {
|
|
133
|
+
const after = {};
|
|
134
|
+
for (const [i, { id, doc }] of pages.entries()) {
|
|
135
|
+
const n = i + 1;
|
|
136
|
+
after[n] = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
|
|
137
|
+
if (before[n] !== after[n]) await collection.writePage(remote, id, doc);
|
|
138
|
+
}
|
|
139
|
+
for (const n of Object.keys(before).map(Number).filter(n => Number.isFinite(n) && n > pages.length)) {
|
|
140
|
+
await collection.dropPage(remote, pages[0].id.replace(/-\d+$/u, `-${n}`));
|
|
141
|
+
}
|
|
142
|
+
return after;
|
|
143
|
+
}
|
|
@@ -9,7 +9,7 @@ import crypto from 'node:crypto';
|
|
|
9
9
|
import * as wire from '../wire.mjs';
|
|
10
10
|
import * as polls from '../polls.mjs';
|
|
11
11
|
import * as podNotes from '../../pod/notes.mjs';
|
|
12
|
-
import { assertDirectAddressed, replyTarget, rowTags } from './notes.mjs';
|
|
12
|
+
import { assertDirectAddressed, replyTarget, rowTags, slugFor } from './notes.mjs';
|
|
13
13
|
|
|
14
14
|
// How long a poll gathers votes before its Question is rewritten. Every vote
|
|
15
15
|
// changes a number other servers re-read, and rewriting per vote would make a
|
|
@@ -22,7 +22,7 @@ const POLL_REWRITE_MS = 10_000;
|
|
|
22
22
|
* voter pick more than one, and `expiresAt` is when voting stops.
|
|
23
23
|
*/
|
|
24
24
|
export async function publishQuestion(publisher, content, { options = [], multiple = false, expiresAt = null,
|
|
25
|
-
inReplyTo = undefined, visibility = 'public', spoilerText = null, sensitive = false } = {}) {
|
|
25
|
+
inReplyTo = undefined, visibility = 'public', spoilerText = null, sensitive = false, slug: wanted = null } = {}) {
|
|
26
26
|
const { urls } = publisher;
|
|
27
27
|
const priv = visibility === 'private' || visibility === 'direct';
|
|
28
28
|
if (priv) {
|
|
@@ -51,7 +51,9 @@ export async function publishQuestion(publisher, content, { options = [], multip
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const published = new Date().toISOString();
|
|
54
|
-
|
|
54
|
+
// The name the client asked for, when it is plain — the outbox door has
|
|
55
|
+
// already told the client the address it names.
|
|
56
|
+
const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
|
|
55
57
|
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
56
58
|
if (visibility === 'direct') assertDirectAddressed(content, mentions);
|
|
57
59
|
const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
|
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
|
@@ -305,7 +305,17 @@ export class PodStore {
|
|
|
305
305
|
const b = this.read('blocklist.json', {});
|
|
306
306
|
return { domains: b.domains || [], actors: b.actors || [] };
|
|
307
307
|
}
|
|
308
|
-
|
|
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
|
+
}
|
|
309
319
|
// Takes an actor URL or an object URL: the actor list only ever matches the
|
|
310
320
|
// former, the domain list matches either.
|
|
311
321
|
isBlocked(url) {
|