fedipod-server 0.11.0 → 0.13.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 +22 -6
- package/dist/claims.d.ts +8 -0
- package/dist/claims.js +10 -0
- package/dist/handler.d.ts +13 -0
- package/dist/handler.js +56 -11
- package/dist/handler.jsonld +8 -0
- package/dist/store-pod.js +18 -4
- package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +170 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/graphview.mjs +269 -0
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +142 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +199 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/server/embed.mjs +405 -0
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +1 -1
- package/run-agent.mjs +33 -25
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +14 -16
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/boot.mjs +2 -3
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/boot.js +22 -3
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +21913 -5446
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/keys-browser.mjs +27 -4
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/signup.mjs +2 -3
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/boot.js +22 -3
- package/web/app/site/sw.js +21913 -5446
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/embed.mjs +0 -220
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// notes.mjs — a note of the actor's own going up: the containers it needs
|
|
2
|
+
// (media, and the owner-only one private posts live in), who it mentions,
|
|
3
|
+
// publishing it, and editing it afterwards.
|
|
4
|
+
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import * as wire from '../wire.mjs';
|
|
7
|
+
import * as containers from '../../pod/containers.mjs';
|
|
8
|
+
import * as podNotes from '../../pod/notes.mjs';
|
|
9
|
+
|
|
10
|
+
// Media container on the remote pod — public-Read like notes, created lazily
|
|
11
|
+
// at first upload (idempotent; the flag only saves round-trips).
|
|
12
|
+
// Ask before writing. `_mediaReady` is per PROCESS, and a service worker is
|
|
13
|
+
// restarted whenever the browser feels like it — so on the browser build this
|
|
14
|
+
// re-created a container that has existed since sign-up, and rewrote its ACL,
|
|
15
|
+
// on every restart. A HEAD is one request and usually the only one.
|
|
16
|
+
export async function ensureMediaContainer(publisher) {
|
|
17
|
+
if (publisher._mediaReady) return;
|
|
18
|
+
if (await containers.exists(publisher.remote, publisher.urls.media)) { publisher._mediaReady = true; return; }
|
|
19
|
+
await containers.provisionPublic(publisher.remote, publisher.urls.media);
|
|
20
|
+
publisher._mediaReady = true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Whether the canary this class writes is already there. Only a definite
|
|
24
|
+
// "yes" counts: anything else falls through to the write, which is
|
|
25
|
+
// idempotent anyway, so a failed probe costs a request and never correctness.
|
|
26
|
+
export function containerExists(publisher, base) { return containers.exists(publisher.remote, base); }
|
|
27
|
+
|
|
28
|
+
// The owner-only container that followers-only and direct posts live in.
|
|
29
|
+
// Its ACL is set once; every note under it inherits.
|
|
30
|
+
export async function ensurePrivateContainer(publisher) {
|
|
31
|
+
if (publisher._privateContainer) return;
|
|
32
|
+
// Same reasoning as ensureMediaContainer: probe before provisioning, so a
|
|
33
|
+
// worker restart is not a re-provision. The ACL is NOT skipped on the
|
|
34
|
+
// strength of the container existing alone — ensurePrivateAcls re-checks
|
|
35
|
+
// that separately on every connect, which is the control that matters here.
|
|
36
|
+
if (await containers.exists(publisher.remote, publisher.urls.privateNotes)) { publisher._privateContainer = true; return; }
|
|
37
|
+
await containers.provisionOwnerOnly(publisher.remote, publisher.urls.privateNotes);
|
|
38
|
+
publisher._privateContainer = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Whether the pod actually enforces that ACL: a bare, unauthenticated read
|
|
42
|
+
// of the private container's canary must be refused. Returns true, or the
|
|
43
|
+
// reason private posts stay off. A definite answer is cached for the run; a
|
|
44
|
+
// network failure is not, so a pod that was briefly unreachable is asked
|
|
45
|
+
// again rather than refused forever.
|
|
46
|
+
export async function privateReady(publisher) {
|
|
47
|
+
if (publisher._privateVerdict !== undefined) return publisher._privateVerdict;
|
|
48
|
+
try {
|
|
49
|
+
await publisher.ensurePrivateContainer();
|
|
50
|
+
// The probe is credential-free and bypasses RemotePod's url map, so a
|
|
51
|
+
// fronted identity must map the advertised private container back to the
|
|
52
|
+
// pod itself — the ACL we are testing is the pod's.
|
|
53
|
+
const pn = publisher.urls.toPod ? publisher.urls.toPod(publisher.urls.privateNotes) : publisher.urls.privateNotes;
|
|
54
|
+
const verdict = await containers.probePrivateEnforcement(publisher.probeFetch || fetch, pn + '.keep');
|
|
55
|
+
publisher._privateVerdict = verdict === true ? true : `${verdict} — private posts stay off it`;
|
|
56
|
+
return publisher._privateVerdict;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return `could not verify that the pod protects private posts (${e.message})`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Compose → wire note on remote pod + RDF truth locally + deliver Create.
|
|
63
|
+
// Who this text mentions, resolved. A mention nobody can resolve stays plain
|
|
64
|
+
// text rather than failing the post. The text itself decides: trim a handle
|
|
65
|
+
// out and that person is not notified, which is what every fediverse client
|
|
66
|
+
// leads people to expect. A Group named in the parent is the one thing
|
|
67
|
+
// carried forward regardless — drop it and the group stops carrying the
|
|
68
|
+
// thread.
|
|
69
|
+
export async function mentionsFor(publisher, content, inReplyTo) {
|
|
70
|
+
const inText = new Set(wire.mentionsIn(content));
|
|
71
|
+
const carried = inReplyTo
|
|
72
|
+
? (publisher.store.getStatuses().find(s => s.noteId === inReplyTo)?.mentions || [])
|
|
73
|
+
.map(m => String(m.name || '').replace(/^@/, '')).filter(Boolean)
|
|
74
|
+
: [];
|
|
75
|
+
const mentions = [];
|
|
76
|
+
for (const handle of [...new Set([...inText, ...carried])]) {
|
|
77
|
+
if (!publisher.resolveMention) break;
|
|
78
|
+
const doc = await publisher.resolveMention(handle).catch(() => null);
|
|
79
|
+
if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
|
|
80
|
+
if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
|
|
81
|
+
mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
|
|
82
|
+
}
|
|
83
|
+
return mentions;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
|
|
87
|
+
const { urls } = publisher;
|
|
88
|
+
const priv = visibility === 'private' || visibility === 'direct';
|
|
89
|
+
if (priv) {
|
|
90
|
+
const ready = await publisher.privateReady();
|
|
91
|
+
if (ready !== true) throw new Error(ready);
|
|
92
|
+
}
|
|
93
|
+
const published = new Date().toISOString();
|
|
94
|
+
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
95
|
+
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
96
|
+
const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
|
|
97
|
+
visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes });
|
|
98
|
+
|
|
99
|
+
await podNotes.write(publisher.remote, note.id, note);
|
|
100
|
+
// Empty, but present: a dangling `replies` that 404s is worse than none.
|
|
101
|
+
await podNotes.writeEmptyReplies(publisher.remote, wire.repliesId(note.id),
|
|
102
|
+
wire.collection(wire.repliesId(note.id), []));
|
|
103
|
+
// The outbox is the PUBLIC index; a private or direct post is not in it.
|
|
104
|
+
if (!priv) await publisher.recordOutbox(note.id);
|
|
105
|
+
|
|
106
|
+
publisher.store.addStatus({
|
|
107
|
+
noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
|
|
108
|
+
kind: 'post', slug, text: content, visibility,
|
|
109
|
+
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
110
|
+
...(attachments?.length ? { attachments } : {}),
|
|
111
|
+
...(note.tag?.length ? { mentions: note.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const create = wire.createActivity(note, urls);
|
|
115
|
+
// Published as its own document: a group that carries this post wraps the
|
|
116
|
+
// whole activity, and the receiving server resolves it by fetching this id.
|
|
117
|
+
// It inherits the notes container's public-Read acl:default.
|
|
118
|
+
await podNotes.writeCreate(publisher.remote, create.id, create);
|
|
119
|
+
const contacts = publisher.store.getContacts();
|
|
120
|
+
// A direct post goes to the people it names and to nobody else.
|
|
121
|
+
const inboxes = [...new Set([
|
|
122
|
+
...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
123
|
+
...mentions.map(m => m.inbox),
|
|
124
|
+
].filter(Boolean))];
|
|
125
|
+
await publisher.deliverer.deliverToAll(inboxes, create);
|
|
126
|
+
publisher.log(`note published: ${note.id} → ${inboxes.length} inbox(es)`);
|
|
127
|
+
|
|
128
|
+
// Bluesky mirror: PUBLIC posts only — unlisted, followers-only and direct
|
|
129
|
+
// are never carried off the fediverse. A mirror failure never fails the
|
|
130
|
+
// post; it is logged and recorded on the status for the admin page.
|
|
131
|
+
if (visibility === 'public' && publisher.atproto?.connected()
|
|
132
|
+
&& publisher.store.getConfig()?.atproto?.crossPost) {
|
|
133
|
+
try {
|
|
134
|
+
const mirror = await publisher.atproto.crossPost(
|
|
135
|
+
{ text: content, published, attachments }, { noteUrl: note.id });
|
|
136
|
+
publisher.store.updateStatus(note.id, { atproto: mirror });
|
|
137
|
+
publisher.log(`cross-posted to bluesky: ${mirror.uri}${mirror.truncated ? ' (truncated, links back)' : ''}`);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
publisher.store.updateStatus(note.id, { atproto: { error: e.message } });
|
|
140
|
+
publisher.log(`bluesky cross-post failed: ${e.message}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return note;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// An edit keeps the note's id, slug and published time; `updated` is the
|
|
147
|
+
// edit's own stamp. The pod documents are overwritten in place — the Create
|
|
148
|
+
// too, so a group's Announce resolves to the edited text — and an Update
|
|
149
|
+
// goes everywhere the Create went.
|
|
150
|
+
export async function updateNote(publisher, s, { content, spoilerText = null, attachments = null } = {}) {
|
|
151
|
+
const { urls } = publisher;
|
|
152
|
+
const updated = new Date().toISOString();
|
|
153
|
+
const inText = new Set(wire.mentionsIn(content));
|
|
154
|
+
const mentions = [];
|
|
155
|
+
for (const handle of inText) {
|
|
156
|
+
if (!publisher.resolveMention) break;
|
|
157
|
+
const doc = await publisher.resolveMention(handle).catch(() => null);
|
|
158
|
+
if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
|
|
159
|
+
mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: doc.endpoints?.sharedInbox || doc.inbox });
|
|
160
|
+
}
|
|
161
|
+
const atts = attachments ?? s.attachments ?? [];
|
|
162
|
+
// The note stays in the container its visibility put it in; a recovered
|
|
163
|
+
// post may carry no slug, but the note id already contains it.
|
|
164
|
+
const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
|
|
165
|
+
const slug = s.slug || String(s.noteId).slice(container.length);
|
|
166
|
+
const note = wire.noteDoc({
|
|
167
|
+
urls, slug, content, published: s.published, inReplyTo: s.inReplyTo,
|
|
168
|
+
attachments: atts, mentions, visibility: s.visibility || 'public',
|
|
169
|
+
summary: spoilerText, updated, container,
|
|
170
|
+
});
|
|
171
|
+
await podNotes.write(publisher.remote, note.id, note);
|
|
172
|
+
await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
|
|
173
|
+
const patched = publisher.store.updateStatus(s.noteId, {
|
|
174
|
+
content: note.content, text: content, editedAt: updated,
|
|
175
|
+
spoiler: spoilerText || undefined,
|
|
176
|
+
attachments: atts.length ? atts : undefined,
|
|
177
|
+
mentions: note.tag?.length ? note.tag.map(t => ({ href: t.href, name: t.name })) : undefined,
|
|
178
|
+
});
|
|
179
|
+
const update = wire.updateActivity(note, urls);
|
|
180
|
+
const contacts = publisher.store.getContacts();
|
|
181
|
+
const inboxes = [...new Set([
|
|
182
|
+
...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
183
|
+
...mentions.map(m => m.inbox),
|
|
184
|
+
].filter(Boolean))];
|
|
185
|
+
await publisher.deliverer.deliverToAll(inboxes, update);
|
|
186
|
+
publisher.log(`note edited: ${note.id} → ${inboxes.length} inbox(es)`);
|
|
187
|
+
return patched;
|
|
188
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// questions.mjs — polls.
|
|
2
|
+
//
|
|
3
|
+
// A poll is published as a Question and answered by ordinary replies naming
|
|
4
|
+
// an option, so the count is ours to keep and ours to republish. The roster
|
|
5
|
+
// of who chose what lives in agent state (polls.VOTES_DOC); the tallies on
|
|
6
|
+
// the status row and in the pod document are always derived from it.
|
|
7
|
+
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import * as wire from '../wire.mjs';
|
|
10
|
+
import * as polls from '../polls.mjs';
|
|
11
|
+
import * as podNotes from '../../pod/notes.mjs';
|
|
12
|
+
|
|
13
|
+
// How long a poll gathers votes before its Question is rewritten. Every vote
|
|
14
|
+
// changes a number other servers re-read, and rewriting per vote would make a
|
|
15
|
+
// busy poll a steady write stream against the pod. A burst costs one rewrite
|
|
16
|
+
// and one Update instead.
|
|
17
|
+
const POLL_REWRITE_MS = 10_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Publish a poll. `options` is a list of choice titles, `multiple` lets a
|
|
21
|
+
* voter pick more than one, and `expiresAt` is when voting stops.
|
|
22
|
+
*/
|
|
23
|
+
export async function publishQuestion(publisher, content, { options = [], multiple = false, expiresAt = null,
|
|
24
|
+
inReplyTo = undefined, visibility = 'public', spoilerText = null } = {}) {
|
|
25
|
+
const { urls } = publisher;
|
|
26
|
+
const priv = visibility === 'private' || visibility === 'direct';
|
|
27
|
+
if (priv) {
|
|
28
|
+
const ready = await publisher.privateReady();
|
|
29
|
+
if (ready !== true) throw new Error(ready);
|
|
30
|
+
}
|
|
31
|
+
const titles = [].concat(options).map(o => String(o ?? '').trim()).filter(Boolean);
|
|
32
|
+
if (titles.length < 2) throw new Error('a poll needs at least two options');
|
|
33
|
+
// Options are matched BY NAME when a vote arrives — that is the whole of
|
|
34
|
+
// the convention — so two options reading the same are one option that
|
|
35
|
+
// cannot be told apart.
|
|
36
|
+
if (new Set(titles).size !== titles.length) throw new Error('a poll’s options must differ from one another');
|
|
37
|
+
// Bounded here as well as at the client API, because the outbox is a
|
|
38
|
+
// second way in and an unbounded poll is a document a stranger sizes.
|
|
39
|
+
if (titles.length > polls.MAX_OPTIONS) throw new Error(`a poll takes at most ${polls.MAX_OPTIONS} options`);
|
|
40
|
+
if (titles.some(t => t.length > polls.MAX_OPTION_CHARS)) {
|
|
41
|
+
throw new Error(`a poll option is at most ${polls.MAX_OPTION_CHARS} characters`);
|
|
42
|
+
}
|
|
43
|
+
if (expiresAt) {
|
|
44
|
+
const ends = Date.parse(expiresAt);
|
|
45
|
+
if (!Number.isFinite(ends)) throw new Error('the closing time is not a date');
|
|
46
|
+
const seconds = (ends - Date.now()) / 1000;
|
|
47
|
+
if (seconds < polls.MIN_SECONDS || seconds > polls.MAX_SECONDS) {
|
|
48
|
+
throw new Error(`a poll runs between ${polls.MIN_SECONDS} and ${polls.MAX_SECONDS} seconds`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const published = new Date().toISOString();
|
|
53
|
+
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
54
|
+
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
55
|
+
const poll = {
|
|
56
|
+
multiple: !!multiple,
|
|
57
|
+
expiresAt: expiresAt || null,
|
|
58
|
+
closed: null,
|
|
59
|
+
options: titles.map(title => ({ title, votes: 0 })),
|
|
60
|
+
votersCount: 0,
|
|
61
|
+
// Resolved once, here: a tally rewrite must not cost a webfinger lookup
|
|
62
|
+
// per vote for people the poll named.
|
|
63
|
+
mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
|
|
64
|
+
};
|
|
65
|
+
const question = wire.questionDoc({
|
|
66
|
+
urls, slug, content, published, inReplyTo, attachments: [], mentions,
|
|
67
|
+
visibility, summary: spoilerText,
|
|
68
|
+
container: priv ? urls.privateNotes : urls.notes,
|
|
69
|
+
options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await podNotes.write(publisher.remote, question.id, question);
|
|
73
|
+
// Empty, but present: a dangling `replies` that 404s is worse than none.
|
|
74
|
+
await podNotes.writeEmptyReplies(publisher.remote, wire.repliesId(question.id),
|
|
75
|
+
wire.collection(wire.repliesId(question.id), []));
|
|
76
|
+
if (!priv) await publisher.recordOutbox(question.id);
|
|
77
|
+
publisher.store.addStatus({
|
|
78
|
+
noteId: question.id, actor: urls.actor, content: question.content, published,
|
|
79
|
+
kind: 'post', slug, text: content, visibility, poll, inReplyTo,
|
|
80
|
+
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
81
|
+
...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const create = publisher._pollActivity('Create', question, wire.createActivityId(question.id));
|
|
85
|
+
await podNotes.writeCreate(publisher.remote, create.id, create);
|
|
86
|
+
const contacts = publisher.store.getContacts();
|
|
87
|
+
const inboxes = [...new Set([
|
|
88
|
+
...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
89
|
+
...poll.mentionInboxes,
|
|
90
|
+
].filter(Boolean))];
|
|
91
|
+
await publisher.deliverer.deliverToAll(inboxes, create);
|
|
92
|
+
publisher.log(`poll published: ${question.id} (${titles.length} options) → ${inboxes.length} inbox(es)`);
|
|
93
|
+
return question;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The Create or Update carrying a Question. The context is hoisted onto the
|
|
97
|
+
// activity and the embedded object keeps none: a nested @context is legal
|
|
98
|
+
// JSON-LD but not every server reads one, and votersCount is declared there.
|
|
99
|
+
export function pollActivity(publisher, type, question, id) {
|
|
100
|
+
const { '@context': ctx, ...object } = question;
|
|
101
|
+
return {
|
|
102
|
+
'@context': ctx, id, type,
|
|
103
|
+
actor: publisher.urls.actor,
|
|
104
|
+
published: question.published,
|
|
105
|
+
to: question.to, cc: question.cc,
|
|
106
|
+
object,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One vote on one of OUR polls, named by the option's title. Returns true
|
|
112
|
+
* when it counted — a second answer to a single-choice poll, an option we do
|
|
113
|
+
* not offer, or a poll that has closed all count for nothing.
|
|
114
|
+
*/
|
|
115
|
+
export async function recordVote(publisher, questionId, actor, optionName) {
|
|
116
|
+
const s = publisher.store.getStatuses().find(x => x.noteId === questionId);
|
|
117
|
+
if (!s?.poll || s.kind !== 'post' || s.actor !== publisher.urls.actor) return false;
|
|
118
|
+
if (polls.pollClosed(s.poll)) return false;
|
|
119
|
+
const index = polls.optionIndex(s.poll, optionName);
|
|
120
|
+
if (index < 0) return false;
|
|
121
|
+
const all = publisher.store.read(polls.VOTES_DOC, {});
|
|
122
|
+
const { roster, changed } = polls.addVote(all[questionId] || {}, polls.voterKey(actor), index,
|
|
123
|
+
{ multiple: !!s.poll.multiple });
|
|
124
|
+
if (!changed) return false;
|
|
125
|
+
publisher.store.write(polls.VOTES_DOC, { ...all, [questionId]: roster });
|
|
126
|
+
publisher.store.updateStatus(questionId, { poll: polls.withTally(s.poll, roster) });
|
|
127
|
+
publisher._pollDirty(questionId);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Open the rewrite window for a poll whose count moved. Already open is
|
|
132
|
+
// already enough: the whole point is that a burst costs one rewrite.
|
|
133
|
+
export function pollDirty(publisher, questionId) {
|
|
134
|
+
if (publisher.pollTimers.has(questionId)) return;
|
|
135
|
+
const t = setTimeout(() => {
|
|
136
|
+
publisher.pollTimers.delete(questionId);
|
|
137
|
+
publisher.republishPoll(questionId).catch(e => publisher.log(`poll rewrite: ${e.message}`));
|
|
138
|
+
}, POLL_REWRITE_MS);
|
|
139
|
+
t.unref?.();
|
|
140
|
+
publisher.pollTimers.set(questionId, t);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Write the poll's current count back to the pod and tell everyone who has
|
|
145
|
+
* it. `closing` stamps it shut, which is a one-way door.
|
|
146
|
+
*/
|
|
147
|
+
export async function republishPoll(publisher, questionId, { closing = null } = {}) {
|
|
148
|
+
const s = publisher.store.getStatuses().find(x => x.noteId === questionId);
|
|
149
|
+
if (!s?.poll) return null;
|
|
150
|
+
const { urls } = publisher;
|
|
151
|
+
const roster = publisher.store.read(polls.VOTES_DOC, {})[questionId] || {};
|
|
152
|
+
// A shut poll whose roster has been retired keeps the counts on its row:
|
|
153
|
+
// deriving them from an empty roster would publish a poll nobody voted in.
|
|
154
|
+
const shut = closing || s.poll.closed;
|
|
155
|
+
const counted = shut && !Object.keys(roster).length ? s.poll : polls.withTally(s.poll, roster);
|
|
156
|
+
const poll = { ...counted, ...(closing ? { closed: closing } : {}) };
|
|
157
|
+
const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
|
|
158
|
+
const slug = s.slug || String(s.noteId).slice(container.length);
|
|
159
|
+
const mentions = (s.mentions || []).map(m => ({
|
|
160
|
+
handle: String(m.name || '').replace(/^@/, ''), actor: m.href, page: null, inbox: null,
|
|
161
|
+
}));
|
|
162
|
+
const question = wire.questionDoc({
|
|
163
|
+
urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
|
|
164
|
+
attachments: [], mentions,
|
|
165
|
+
visibility: s.visibility || 'public', summary: s.spoiler || null, container,
|
|
166
|
+
options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
|
|
167
|
+
closed: poll.closed, votersCount: poll.votersCount || 0,
|
|
168
|
+
});
|
|
169
|
+
await podNotes.write(publisher.remote, question.id, question);
|
|
170
|
+
// The Create is overwritten too, so a group's Announce resolves to the
|
|
171
|
+
// current count rather than to the one the poll opened with.
|
|
172
|
+
await podNotes.writeCreate(publisher.remote, wire.createActivityId(question.id),
|
|
173
|
+
publisher._pollActivity('Create', question, wire.createActivityId(question.id)));
|
|
174
|
+
publisher.store.updateStatus(questionId, { poll });
|
|
175
|
+
|
|
176
|
+
// Not `updated`: a changed count is not an edit, and stamping one would
|
|
177
|
+
// have every client show the poll as edited each time somebody voted.
|
|
178
|
+
//
|
|
179
|
+
// The Update is named after the STATE it carries rather than the moment it
|
|
180
|
+
// was sent. A clock only tells two rewrites apart when they fall in
|
|
181
|
+
// different milliseconds, and a receiving server that has seen an activity
|
|
182
|
+
// id drops the next one wearing it — which would quietly freeze the count.
|
|
183
|
+
// Naming the state means an id changes exactly when there is something new
|
|
184
|
+
// to say, and two sends of the same numbers are the duplicate they look
|
|
185
|
+
// like.
|
|
186
|
+
const stamp = crypto.createHash('sha256').update(JSON.stringify([
|
|
187
|
+
poll.options.map(o => o.votes || 0), poll.votersCount || 0, poll.closed || '',
|
|
188
|
+
])).digest('hex').slice(0, 12);
|
|
189
|
+
const update = publisher._pollActivity('Update', question, `${question.id}#poll-${stamp}`);
|
|
190
|
+
const contacts = publisher.store.getContacts();
|
|
191
|
+
const inboxes = [...new Set([
|
|
192
|
+
...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
193
|
+
...(s.poll.mentionInboxes || []),
|
|
194
|
+
].filter(Boolean))];
|
|
195
|
+
await publisher.deliverer.deliverToAll(inboxes, update);
|
|
196
|
+
publisher.log(`poll ${closing ? 'closed' : 'count published'}: ${question.id} → ${inboxes.length} inbox(es)`);
|
|
197
|
+
return poll;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Shut any poll whose time is up. Called from the agent's sweep. Closing is
|
|
202
|
+
* recorded BEFORE the republish, so a failed republish cannot leave a poll
|
|
203
|
+
* open and collecting votes it has already refused.
|
|
204
|
+
*/
|
|
205
|
+
export async function closeDuePolls(publisher, now = Date.now()) {
|
|
206
|
+
const due = publisher.store.getStatuses().filter(s => s.kind === 'post' && s.poll
|
|
207
|
+
&& !s.poll.closed && s.poll.expiresAt && Date.parse(s.poll.expiresAt) <= now);
|
|
208
|
+
for (const s of due) {
|
|
209
|
+
const closed = new Date(Math.min(now, Date.parse(s.poll.expiresAt) || now)).toISOString();
|
|
210
|
+
clearTimeout(publisher.pollTimers.get(s.noteId));
|
|
211
|
+
publisher.pollTimers.delete(s.noteId);
|
|
212
|
+
publisher.store.updateStatus(s.noteId, { poll: { ...s.poll, closed } });
|
|
213
|
+
const done = await publisher.republishPoll(s.noteId, { closing: closed })
|
|
214
|
+
.catch(e => { publisher.log(`poll close ${s.noteId}: ${e.message}`); return null; });
|
|
215
|
+
// The roster only ever answered one question — has this person already
|
|
216
|
+
// voted — and a shut poll has stopped asking it. Dropping it keeps a
|
|
217
|
+
// document we serialize whole from carrying every poll's voters forever.
|
|
218
|
+
if (done) {
|
|
219
|
+
const all = publisher.store.read(polls.VOTES_DOC, {});
|
|
220
|
+
if (all[s.noteId]) {
|
|
221
|
+
delete all[s.noteId];
|
|
222
|
+
publisher.store.write(polls.VOTES_DOC, all);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return due.length;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Stop the pending rewrite windows. Called at shutdown. */
|
|
230
|
+
export function stopPolls(publisher) {
|
|
231
|
+
for (const t of publisher.pollTimers.values()) clearTimeout(t);
|
|
232
|
+
publisher.pollTimers.clear();
|
|
233
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// restore.mjs — a machine that is behind the pod catching up: followers and
|
|
2
|
+
// outbox entries the pod carries and this machine never heard of, and the
|
|
3
|
+
// actor's own posts rebuilt from the published outbox.
|
|
4
|
+
|
|
5
|
+
import * as wire from '../wire.mjs';
|
|
6
|
+
import * as podNotes from '../../pod/notes.mjs';
|
|
7
|
+
import { readLenient } from '../as2.mjs';
|
|
8
|
+
|
|
9
|
+
const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
10
|
+
const REBUILD_MAX_PER_RUN = 200;
|
|
11
|
+
|
|
12
|
+
// Anyone the pod says follows us that we have no record of, and never
|
|
13
|
+
// deliberately removed. In the steady state there is nobody: the pod's list is
|
|
14
|
+
// written from this one. They appear when the local half is BEHIND the pod —
|
|
15
|
+
// a restored backup, a home copied off a dead machine — and republishing
|
|
16
|
+
// blindly would delete them from the wire and, worse, stop delivering to them.
|
|
17
|
+
//
|
|
18
|
+
// Removals are what makes this safe to do unconditionally: `dropFollower`
|
|
19
|
+
// records every unfollow, ejection and account deletion, so a returning name
|
|
20
|
+
// is either genuinely still a follower or genuinely a mistake we made.
|
|
21
|
+
export async function reconcileFollowers(publisher, contacts) {
|
|
22
|
+
let published = [];
|
|
23
|
+
try {
|
|
24
|
+
published = (await publisher.readPublishedFollowers()) || []; // walks pages
|
|
25
|
+
} catch { return 0; } // no list to reconcile against
|
|
26
|
+
|
|
27
|
+
const known = new Set(contacts.followers.map(f => f.actor));
|
|
28
|
+
const removed = new Set((contacts.removedFollowers || []).map(r => r.actor));
|
|
29
|
+
const missing = published.filter(a => typeof a === 'string' && !known.has(a) && !removed.has(a));
|
|
30
|
+
if (!missing.length) return 0;
|
|
31
|
+
|
|
32
|
+
// An inbox is what delivery needs, and only the actor document has it — so
|
|
33
|
+
// recovering a follower costs one fetch each. Capped: a list this wrong is
|
|
34
|
+
// a restore, and the rest catch up on the next publish.
|
|
35
|
+
let recovered = 0;
|
|
36
|
+
for (const actor of missing.slice(0, 200)) {
|
|
37
|
+
try {
|
|
38
|
+
const res = await publisher.deliverer.signedFetch(actor, { headers: { accept: ACCEPT_AP } });
|
|
39
|
+
if (!res.ok) continue;
|
|
40
|
+
// The graph, not the compacted copy: `inbox` is `ldp:inbox` rather than
|
|
41
|
+
// an `as:` term, and what is wanted here is the statement, not a key.
|
|
42
|
+
const { doc, view, degraded } = await readLenient(await res.json());
|
|
43
|
+
const actorDoc = view ?? doc;
|
|
44
|
+
if (degraded) publisher.log?.(`actor ${actor} grounded to read: ${degraded}`);
|
|
45
|
+
if (!actorDoc?.inbox) continue;
|
|
46
|
+
contacts.followers.push({
|
|
47
|
+
actor, inbox: actorDoc.inbox, sharedInbox: actorDoc.endpoints?.sharedInbox || null, recovered: true,
|
|
48
|
+
// Said explicitly, because onUndo reads it: the pod publishes WHO
|
|
49
|
+
// follows, never the id of the Follow that did it, so a recovered
|
|
50
|
+
// record has nothing an Undo can be matched against and must not be
|
|
51
|
+
// evictable by one naming anything at all.
|
|
52
|
+
followId: null,
|
|
53
|
+
});
|
|
54
|
+
recovered++;
|
|
55
|
+
} catch { /* unreachable now; it will be there next time */ }
|
|
56
|
+
}
|
|
57
|
+
if (recovered) {
|
|
58
|
+
publisher.store.setContacts(contacts);
|
|
59
|
+
publisher.log(`reconciled ${recovered} follower(s) the pod knew about and this machine did not `
|
|
60
|
+
+ '— a restored or copied state was behind');
|
|
61
|
+
}
|
|
62
|
+
return recovered;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The same argument as reconcileFollowers, for the other published list. It
|
|
66
|
+
// matters more: the outbox is the INDEX a statuses rebuild reads, so a
|
|
67
|
+
// republish from a restored-and-behind machine would destroy the record of
|
|
68
|
+
// everything this actor ever posted — and destroy it before anyone noticed
|
|
69
|
+
// there was anything to recover.
|
|
70
|
+
//
|
|
71
|
+
// Safe for the same reason: `unrecordOutbox` leaves a tombstone, so an entry
|
|
72
|
+
// the pod still carries is one this machine has not heard of, never one it
|
|
73
|
+
// deliberately took back.
|
|
74
|
+
export async function reconcileOutbox(publisher, outbox) {
|
|
75
|
+
let published = [];
|
|
76
|
+
try {
|
|
77
|
+
published = await publisher.readPublishedOutbox() || [];
|
|
78
|
+
} catch { return 0; }
|
|
79
|
+
if (!Array.isArray(published) || !published.length) return 0;
|
|
80
|
+
|
|
81
|
+
const idOf = (i) => (typeof i === 'string' ? i : i?.id || null);
|
|
82
|
+
const known = new Set(outbox.map(idOf).filter(Boolean));
|
|
83
|
+
const removed = new Set((publisher.store.read('outbox-removed.json', [])).map(r => r.id));
|
|
84
|
+
const missing = published.filter((i) => {
|
|
85
|
+
const id = idOf(i);
|
|
86
|
+
return id && !known.has(id) && !removed.has(id);
|
|
87
|
+
});
|
|
88
|
+
if (!missing.length) return 0;
|
|
89
|
+
// Newest first, like recordOutbox leaves it; the pod's copy is already in
|
|
90
|
+
// that order, so appending the tail is enough to keep both sorted.
|
|
91
|
+
outbox.push(...missing);
|
|
92
|
+
publisher.store.write('outbox.json', outbox);
|
|
93
|
+
publisher.log(`reconciled ${missing.length} outbox entr(ies) the pod carried and this machine did not`);
|
|
94
|
+
return missing.length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Recover this actor's own posts from the pod's public face. The private half
|
|
98
|
+
// lives on this machine now, so a restored backup or a replaced machine loses
|
|
99
|
+
// statuses.json while the pod still serves every note. Followers already come
|
|
100
|
+
// back; this is the other half of the same gap.
|
|
101
|
+
//
|
|
102
|
+
// The INDEX is ap/outbox, not the ap/notes/ listing, and that difference is
|
|
103
|
+
// the safety argument. Deleting a post rewrites the outbox in one PUT, so an
|
|
104
|
+
// entry still there is a post that still stands. The note DOCUMENT can outlive
|
|
105
|
+
// its own deletion — deleteNote's `remote.delete` is a request that can fail —
|
|
106
|
+
// so walking the container can bring back something its author took down.
|
|
107
|
+
// `fromNotes` is for when you would rather have that than lose the post; it is
|
|
108
|
+
// not the default, and it says so where it is offered.
|
|
109
|
+
//
|
|
110
|
+
// MERGE ONLY. A status this machine already holds is left exactly as it is:
|
|
111
|
+
// it carries local facts — favourited, reblogged, the activities an Undo has
|
|
112
|
+
// to name — that the pod knows nothing about. That is also what makes the
|
|
113
|
+
// failure modes harmless: a listing that fails returns nothing, and nothing
|
|
114
|
+
// is what an empty listing recovers.
|
|
115
|
+
export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
|
|
116
|
+
const { urls } = publisher;
|
|
117
|
+
const ids = new Set();
|
|
118
|
+
const boosts = [];
|
|
119
|
+
let indexed = false;
|
|
120
|
+
|
|
121
|
+
const published = await publisher.readPublishedOutbox().catch(() => null);
|
|
122
|
+
if (published) {
|
|
123
|
+
indexed = true;
|
|
124
|
+
for (const item of published) {
|
|
125
|
+
if (typeof item === 'string') { if (item.startsWith(urls.notes)) ids.add(item); }
|
|
126
|
+
else if (item?.type === 'Announce') boosts.push(item);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (fromNotes) {
|
|
130
|
+
// Three documents are published per post — the note, `-create` and
|
|
131
|
+
// `-replies` — plus a `.keep`. Which is which is settled by reading the
|
|
132
|
+
// document below, not by its name: a slug is a date and eight hex
|
|
133
|
+
// characters and says nothing about what it holds.
|
|
134
|
+
for (const child of await podNotes.list(publisher.remote, urls).catch(() => [])) {
|
|
135
|
+
ids.add(child.url);
|
|
136
|
+
}
|
|
137
|
+
indexed = true;
|
|
138
|
+
}
|
|
139
|
+
if (!indexed) return { indexed: 0, recovered: 0, reblogs: 0, landed: false, why: 'the pod would not answer for its outbox' };
|
|
140
|
+
|
|
141
|
+
const statuses = publisher.store.getStatuses();
|
|
142
|
+
const have = new Set(statuses.map(s => s.noteId));
|
|
143
|
+
const removed = new Set(publisher.store.read('outbox-removed.json', []).map(r => r.id));
|
|
144
|
+
const recovered = [];
|
|
145
|
+
// Capped per run: a long-lived actor's rebuild is otherwise one pod request
|
|
146
|
+
// per post it has ever made, in one burst. What is left is picked up by
|
|
147
|
+
// running it again — the merge is idempotent, so that is safe to repeat.
|
|
148
|
+
let budget = REBUILD_MAX_PER_RUN;
|
|
149
|
+
for (const id of ids) {
|
|
150
|
+
if (budget <= 0) { publisher.log(`rebuild: stopping at ${REBUILD_MAX_PER_RUN} this run — run it again for the rest`); break; }
|
|
151
|
+
if (have.has(id) || removed.has(id)) continue;
|
|
152
|
+
budget--;
|
|
153
|
+
const note = await podNotes.read(publisher.remote, id).catch(() => null);
|
|
154
|
+
if (note?.type !== 'Note' || note.id !== id || note.attributedTo !== urls.actor) continue;
|
|
155
|
+
const attachments = wire.attachmentsOf(note);
|
|
156
|
+
const mentions = (Array.isArray(note.tag) ? note.tag : [])
|
|
157
|
+
.filter(t => t?.type === 'Mention' && t.href)
|
|
158
|
+
.map(t => ({ href: t.href, name: t.name }));
|
|
159
|
+
recovered.push({
|
|
160
|
+
noteId: note.id, actor: urls.actor, content: note.content || '',
|
|
161
|
+
published: note.published || null,
|
|
162
|
+
...(note.inReplyTo ? { inReplyTo: note.inReplyTo } : {}),
|
|
163
|
+
kind: 'post', slug: note.id.slice(urls.notes.length),
|
|
164
|
+
...(attachments.length ? { attachments } : {}),
|
|
165
|
+
...(mentions.length ? { mentions } : {}),
|
|
166
|
+
recovered: true,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// A boost is only recoverable for a post we can name — the Announce carries
|
|
171
|
+
// the activity a later Undo needs, but not the boosted post's text, which
|
|
172
|
+
// belongs to whoever wrote it.
|
|
173
|
+
const merged = [...statuses, ...recovered];
|
|
174
|
+
let reblogs = 0;
|
|
175
|
+
for (const act of boosts) {
|
|
176
|
+
const object = typeof act.object === 'string' ? act.object : act.object?.id;
|
|
177
|
+
const s = object && merged.find(x => x.noteId === object);
|
|
178
|
+
if (!s || s.reblogged) continue;
|
|
179
|
+
s.reblogged = true;
|
|
180
|
+
s.announceActivity = act;
|
|
181
|
+
reblogs++;
|
|
182
|
+
}
|
|
183
|
+
if (!recovered.length && !reblogs) return { indexed: ids.size, recovered: 0, reblogs: 0, landed: true };
|
|
184
|
+
|
|
185
|
+
// Written whole and sorted: addStatus unshifts and fires the streaming
|
|
186
|
+
// event, so a loop of it would arrive backwards and push every recovered
|
|
187
|
+
// post at connected clients as new.
|
|
188
|
+
merged.sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
|
|
189
|
+
const kept = merged.slice(0, 1000);
|
|
190
|
+
publisher.store.write('statuses.json', kept);
|
|
191
|
+
const landed = await publisher.store.commit();
|
|
192
|
+
|
|
193
|
+
publisher.log(`rebuilt ${recovered.length} post(s) and ${reblogs} boost(s) from the pod`
|
|
194
|
+
+ `${landed ? '' : ' — THE STATE WRITE DID NOT LAND'}`);
|
|
195
|
+
return {
|
|
196
|
+
indexed: ids.size, recovered: recovered.length, reblogs, landed,
|
|
197
|
+
dropped: Math.max(0, merged.length - kept.length),
|
|
198
|
+
};
|
|
199
|
+
}
|