fedipod 1.7.0 → 1.10.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 +14 -3
- package/gateway.md +16 -0
- package/groups.md +1 -0
- package/lib/client/c2s.mjs +112 -64
- package/lib/client/masto/index.mjs +9 -1
- package/lib/client/masto/render.mjs +5 -3
- package/lib/client/masto/statuses.mjs +7 -4
- package/lib/client/oidc-auth.mjs +10 -3
- package/lib/core/as2.mjs +1 -1
- package/lib/core/contexts/anno.json +126 -0
- package/lib/core/contexts/index.mjs +4 -0
- package/lib/core/contexts/map.json +2 -1
- package/lib/core/deliver.mjs +24 -1
- package/lib/core/intake/activities.mjs +54 -0
- package/lib/core/intake/index.mjs +34 -5
- package/lib/core/intake/notes.mjs +7 -0
- package/lib/core/publisher/collections.mjs +7 -4
- package/lib/core/publisher/index.mjs +49 -27
- package/lib/core/publisher/notes.mjs +154 -7
- package/lib/core/publisher/questions.mjs +11 -7
- package/lib/core/publisher/restore.mjs +27 -2
- package/lib/core/social.mjs +1 -0
- package/lib/core/store.mjs +13 -0
- package/lib/core/wire.mjs +71 -20
- package/lib/device/admin/static.mjs +2 -2
- package/lib/device/admin/surface.mjs +11 -2
- package/lib/gateway/front-core.mjs +76 -6
- package/lib/gateway/gateway-core.mjs +40 -0
- package/lib/gateway/httpsig.mjs +7 -3
- package/lib/pod/actor.mjs +2 -2
- package/lib/pod/transport.mjs +9 -3
- package/package.json +1 -1
- package/run-agent.mjs +11 -0
- package/scripts/refresh-contexts.mjs +5 -0
- package/scripts/stage-site.mjs +1 -0
- package/web/admin/index.html +2 -0
- package/web/admin/upkeep.js +9 -1
- package/web/app/README.md +1 -1
- package/web/app/agent.mjs +12 -0
- package/web/app/boot.mjs +49 -10
- package/web/app/dist/boot.js +47 -7
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +1604 -722
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -6
- package/web/app/site/_redirects +1 -0
- package/web/app/site/admin/index.html +2 -0
- package/web/app/site/admin/upkeep.js +9 -1
- package/web/app/site/boot.js +89 -37
- package/web/app/site/index.html +16 -6
- package/web/app/site/sw.js +1605 -723
- package/web/front/#new-account.html# +0 -43
- package/web/front/new-account.html~ +0 -50
|
@@ -83,6 +83,45 @@ export async function mentionsFor(publisher, content, inReplyTo) {
|
|
|
83
83
|
return mentions;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Who a reply is answering. Their server gets the post whether or not the
|
|
87
|
+
// text names them — that is how the thread stays whole where they are — but
|
|
88
|
+
// no Mention tag is added, so a handle the author trimmed out still means no
|
|
89
|
+
// notification. Nothing for a direct post: it goes to whom it names. The
|
|
90
|
+
// parent is read from the timeline row; a parent the agent never held (a
|
|
91
|
+
// client-to-server reply to any address) is fetched to find its author.
|
|
92
|
+
export async function replyTarget(publisher, inReplyTo, mentions = [], visibility = 'public') {
|
|
93
|
+
if (!inReplyTo || visibility === 'direct' || !publisher.resolveActor) return null;
|
|
94
|
+
const parent = publisher.store.getStatuses().find(s => s.noteId === inReplyTo);
|
|
95
|
+
let actor = parent?.actor || null;
|
|
96
|
+
if (!parent) {
|
|
97
|
+
const doc = await publisher.resolveActor(inReplyTo).catch(() => null);
|
|
98
|
+
const by = doc?.attributedTo;
|
|
99
|
+
actor = typeof by === 'string' ? by : (Array.isArray(by) ? by[0] : by)?.id || null;
|
|
100
|
+
if (typeof actor === 'object') actor = actor?.id || null;
|
|
101
|
+
}
|
|
102
|
+
if (!actor || actor === publisher.urls.actor) return null;
|
|
103
|
+
if (mentions.some(m => m.actor === actor)) return null;
|
|
104
|
+
if (publisher.store.isBlocked?.(actor)) return null;
|
|
105
|
+
const doc = await publisher.resolveActor(actor).catch(() => null);
|
|
106
|
+
const inbox = doc?.endpoints?.sharedInbox || doc?.inbox || null;
|
|
107
|
+
if (!inbox) publisher.log(`reply: no inbox found for ${actor} — they get it only through followers`);
|
|
108
|
+
return { actor, inbox };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Inboxes for actors a client addressed by id — the people in a post's
|
|
112
|
+
// to/cc and the ones in bto/bcc, who are delivered to and never listed.
|
|
113
|
+
export async function inboxesFor(publisher, actorIds = []) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const id of [...new Set(actorIds.filter(a => typeof a === 'string' && a && a !== publisher.urls.actor))]) {
|
|
116
|
+
if (!publisher.resolveActor) break;
|
|
117
|
+
const doc = await publisher.resolveActor(id).catch(() => null);
|
|
118
|
+
const inbox = doc?.endpoints?.sharedInbox || doc?.inbox;
|
|
119
|
+
if (inbox) out.push(inbox);
|
|
120
|
+
else publisher.log(`addressed actor ${id} has no inbox the agent can find — not delivered to`);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
86
125
|
// A direct post goes only to the people it names, so one that names nobody
|
|
87
126
|
// the agent can find would be kept in the private container and delivered
|
|
88
127
|
// to no one, with nothing said. Refused instead, before anything is written,
|
|
@@ -99,7 +138,28 @@ export function assertDirectAddressed(content, mentions) {
|
|
|
99
138
|
throw e;
|
|
100
139
|
}
|
|
101
140
|
|
|
102
|
-
|
|
141
|
+
// A client may name the document it is creating (the Slug of a client-to-server
|
|
142
|
+
// POST). Only a plain name is taken, and only when nothing is there already;
|
|
143
|
+
// otherwise the agent mints one as it always has.
|
|
144
|
+
const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
145
|
+
export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
|
|
146
|
+
async function slugFor(publisher, container, wanted, published) {
|
|
147
|
+
const name = safeSlug(wanted);
|
|
148
|
+
if (name && !(await podNotes.read(publisher.remote, container + name).catch(() => null))) return name;
|
|
149
|
+
return published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// What the author's own timeline shows for an object: its content, else its
|
|
153
|
+
// text under one of the names other vocabularies use, else a link to it.
|
|
154
|
+
export function rowContent(obj) {
|
|
155
|
+
if (typeof obj?.content === 'string' && obj.content.trim()) return wire.sanitizeHtml(obj.content);
|
|
156
|
+
const plain = [obj?.bodyValue, obj?.name, obj?.summary].find((v) => typeof v === 'string' && v.trim());
|
|
157
|
+
if (plain) return wire.contentHtml(plain);
|
|
158
|
+
const esc = (v) => String(v).replace(/[&<>"]/gu, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
159
|
+
return `<p><a href="${esc(obj?.id || '')}">${esc(obj?.type || 'object')}</a></p>`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null, sensitive = false, slug: wanted = null, also = [], deliverTo = [] } = {}) {
|
|
103
163
|
const { urls } = publisher;
|
|
104
164
|
const priv = visibility === 'private' || visibility === 'direct';
|
|
105
165
|
if (priv) {
|
|
@@ -107,11 +167,14 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
107
167
|
if (ready !== true) throw new Error(ready);
|
|
108
168
|
}
|
|
109
169
|
const published = new Date().toISOString();
|
|
110
|
-
const slug =
|
|
170
|
+
const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
|
|
111
171
|
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
112
172
|
if (visibility === 'direct') assertDirectAddressed(content, mentions);
|
|
173
|
+
const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
|
|
113
174
|
const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
|
|
114
|
-
visibility, summary: spoilerText, container: priv ? urls.privateNotes : urls.notes
|
|
175
|
+
visibility, summary: spoilerText, sensitive, container: priv ? urls.privateNotes : urls.notes,
|
|
176
|
+
also: [...also, ...(reply ? [reply.actor] : [])] });
|
|
177
|
+
const addressedInboxes = await inboxesFor(publisher, [...also, ...deliverTo]);
|
|
115
178
|
|
|
116
179
|
await podNotes.write(publisher.remote, note.id, note);
|
|
117
180
|
// Empty, but present: a dangling `replies` that 404s is worse than none.
|
|
@@ -124,9 +187,15 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
124
187
|
noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
|
|
125
188
|
kind: 'post', slug, text: content, visibility,
|
|
126
189
|
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
190
|
+
...(note.sensitive ? { sensitive: true } : {}),
|
|
127
191
|
...(attachments?.length ? { attachments } : {}),
|
|
128
|
-
...(note
|
|
192
|
+
...rowTags(note),
|
|
129
193
|
});
|
|
194
|
+
// The row is what the author's own timeline reads. Land it before answering
|
|
195
|
+
// — the same PUT the debounce would send in 300 ms — so a worker killed or a
|
|
196
|
+
// write refused after the answer cannot leave a post that stands on the pod
|
|
197
|
+
// and reached followers but never shows to its author.
|
|
198
|
+
if (await publisher.store.commit?.() === false) publisher.log(`post published but its timeline row was refused: ${note.id}`);
|
|
130
199
|
|
|
131
200
|
const create = wire.createActivity(note, urls);
|
|
132
201
|
// Published as its own document: a group that carries this post wraps the
|
|
@@ -138,6 +207,8 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
138
207
|
const inboxes = [...new Set([
|
|
139
208
|
...(visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
140
209
|
...mentions.map(m => m.inbox),
|
|
210
|
+
reply?.inbox,
|
|
211
|
+
...addressedInboxes,
|
|
141
212
|
].filter(Boolean))];
|
|
142
213
|
await publisher.deliverer.deliverToAll(inboxes, create);
|
|
143
214
|
publisher.log(`note published: ${note.id} → ${inboxes.length} inbox(es)`);
|
|
@@ -164,7 +235,78 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
164
235
|
// edit's own stamp. The pod documents are overwritten in place — the Create
|
|
165
236
|
// too, so a group's Announce resolves to the edited text — and an Update
|
|
166
237
|
// goes everywhere the Create went.
|
|
167
|
-
|
|
238
|
+
// Any object as a post: what a client-to-server Create carries when it is not
|
|
239
|
+
// a Note or a Question — a Web Annotation, say. Stored as sent, with its own
|
|
240
|
+
// context, under this actor; the Create around it is what followers receive.
|
|
241
|
+
// Servers that know the type show it; the rest ignore it, which is the
|
|
242
|
+
// expected outcome.
|
|
243
|
+
//
|
|
244
|
+
// An object already living on this pod (an id under it) is not copied: the
|
|
245
|
+
// Create names it where it is. Anything else is written into the notes
|
|
246
|
+
// container under the client's slug when it is free, else a minted one.
|
|
247
|
+
export async function publishObject(publisher, object, { visibility = 'public', slug: wanted = null, also = [], deliverTo = [] } = {}) {
|
|
248
|
+
const { urls } = publisher;
|
|
249
|
+
const priv = visibility === 'private' || visibility === 'direct';
|
|
250
|
+
if (priv) {
|
|
251
|
+
const ready = await publisher.privateReady();
|
|
252
|
+
if (ready !== true) throw new Error(ready);
|
|
253
|
+
}
|
|
254
|
+
const published = new Date().toISOString();
|
|
255
|
+
const container = priv ? urls.privateNotes : urls.notes;
|
|
256
|
+
const pod = publisher.config?.remotePod || urls.base;
|
|
257
|
+
const ownId = typeof object.id === 'string' && /^https?:\/\//u.test(object.id) && object.id.startsWith(pod)
|
|
258
|
+
? object.id : null;
|
|
259
|
+
const name = await slugFor(publisher, container, wanted, published);
|
|
260
|
+
const addressed = wire.addressing(urls, visibility, visibility === 'direct' ? also : []);
|
|
261
|
+
if (visibility !== 'direct') addressed.cc = [...new Set([...addressed.cc, ...also])];
|
|
262
|
+
let doc = null;
|
|
263
|
+
const id = ownId || container + name;
|
|
264
|
+
if (!ownId) {
|
|
265
|
+
// bto and bcc are for delivery alone: they never appear in what is
|
|
266
|
+
// stored, served or sent (ActivityPub §6).
|
|
267
|
+
const { bto, bcc, ...sent } = object; // eslint-disable-line no-unused-vars
|
|
268
|
+
doc = { ...sent, id, attributedTo: urls.actor, published: object.published || published, to: addressed.to, cc: addressed.cc };
|
|
269
|
+
if (!doc['@context']) doc['@context'] = wire.AS_CTX;
|
|
270
|
+
if (typeof doc.content === 'string') doc.content = wire.sanitizeHtml(doc.content);
|
|
271
|
+
await podNotes.write(publisher.remote, id, doc);
|
|
272
|
+
}
|
|
273
|
+
if (!priv) await publisher.recordOutbox(id);
|
|
274
|
+
const row = doc || object;
|
|
275
|
+
publisher.store.addStatus({
|
|
276
|
+
noteId: id, actor: urls.actor, content: rowContent(row), published: row.published || published,
|
|
277
|
+
kind: 'post', slug: name, visibility,
|
|
278
|
+
});
|
|
279
|
+
if (await publisher.store.commit?.() === false) publisher.log(`object published but its timeline row was refused: ${id}`);
|
|
280
|
+
|
|
281
|
+
// The Create sits beside the object; for an object living elsewhere on the
|
|
282
|
+
// pod it sits in the notes container, whose ACL is public Read.
|
|
283
|
+
const createId = doc ? wire.createActivityId(id) : wire.createActivityId(container + name);
|
|
284
|
+
const create = {
|
|
285
|
+
'@context': wire.AS_CTX, id: createId, type: 'Create', actor: urls.actor,
|
|
286
|
+
published: row.published || published, to: addressed.to, cc: addressed.cc,
|
|
287
|
+
object: doc || id,
|
|
288
|
+
};
|
|
289
|
+
await podNotes.writeCreate(publisher.remote, create.id, create);
|
|
290
|
+
const contacts = publisher.store.getContacts();
|
|
291
|
+
const inboxes = [...new Set([
|
|
292
|
+
...(visibility === 'direct' ? [] : contacts.followers.map((f) => f.sharedInbox || f.inbox)),
|
|
293
|
+
...(await inboxesFor(publisher, [...also, ...deliverTo])),
|
|
294
|
+
].filter(Boolean))];
|
|
295
|
+
await publisher.deliverer.deliverToAll(inboxes, create);
|
|
296
|
+
publisher.log(`${row.type || 'object'} published: ${id} → ${inboxes.length} inbox(es)`);
|
|
297
|
+
return { id, createId, copied: !ownId };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// What the author's timeline row keeps of a note's tags: the people it
|
|
301
|
+
// mentions and the hashtags it carries, each as the client-facing shape.
|
|
302
|
+
export function rowTags(note) {
|
|
303
|
+
const tags = [].concat(note.tag || []);
|
|
304
|
+
const mentions = tags.filter(t => t.type === 'Mention').map(t => ({ href: t.href, name: t.name }));
|
|
305
|
+
const hashtags = tags.filter(t => t.type === 'Hashtag').map(t => ({ name: String(t.name).replace(/^#/, ''), url: t.href }));
|
|
306
|
+
return { ...(mentions.length ? { mentions } : {}), ...(hashtags.length ? { tags: hashtags } : {}) };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function updateNote(publisher, s, { content, spoilerText = null, sensitive = null, attachments = null } = {}) {
|
|
168
310
|
const { urls } = publisher;
|
|
169
311
|
const updated = new Date().toISOString();
|
|
170
312
|
const inText = new Set(wire.mentionsIn(content));
|
|
@@ -180,24 +322,29 @@ export async function updateNote(publisher, s, { content, spoilerText = null, at
|
|
|
180
322
|
// post may carry no slug, but the note id already contains it.
|
|
181
323
|
const container = String(s.noteId).startsWith(urls.privateNotes) ? urls.privateNotes : urls.notes;
|
|
182
324
|
const slug = s.slug || String(s.noteId).slice(container.length);
|
|
325
|
+
const reply = await replyTarget(publisher, s.inReplyTo, mentions, s.visibility || 'public');
|
|
183
326
|
const note = wire.noteDoc({
|
|
184
327
|
urls, slug, content, published: s.published, inReplyTo: s.inReplyTo,
|
|
185
328
|
attachments: atts, mentions, visibility: s.visibility || 'public',
|
|
186
|
-
summary: spoilerText, updated, container,
|
|
329
|
+
summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
|
|
330
|
+
also: reply ? [reply.actor] : [],
|
|
187
331
|
});
|
|
188
332
|
await podNotes.write(publisher.remote, note.id, note);
|
|
189
333
|
await podNotes.writeCreate(publisher.remote, wire.createActivityId(note.id), wire.createActivity(note, urls));
|
|
190
334
|
const patched = publisher.store.updateStatus(s.noteId, {
|
|
191
335
|
content: note.content, text: content, editedAt: updated,
|
|
192
336
|
spoiler: spoilerText || undefined,
|
|
337
|
+
sensitive: note.sensitive || undefined,
|
|
193
338
|
attachments: atts.length ? atts : undefined,
|
|
194
|
-
mentions: note.
|
|
339
|
+
mentions: rowTags(note).mentions,
|
|
340
|
+
tags: rowTags(note).tags,
|
|
195
341
|
});
|
|
196
342
|
const update = wire.updateActivity(note, urls);
|
|
197
343
|
const contacts = publisher.store.getContacts();
|
|
198
344
|
const inboxes = [...new Set([
|
|
199
345
|
...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
200
346
|
...mentions.map(m => m.inbox),
|
|
347
|
+
reply?.inbox,
|
|
201
348
|
].filter(Boolean))];
|
|
202
349
|
await publisher.deliverer.deliverToAll(inboxes, update);
|
|
203
350
|
publisher.log(`note edited: ${note.id} → ${inboxes.length} inbox(es)`);
|
|
@@ -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 } from './notes.mjs';
|
|
12
|
+
import { assertDirectAddressed, replyTarget, rowTags } 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 } = {}) {
|
|
25
|
+
inReplyTo = undefined, visibility = 'public', spoilerText = null, sensitive = false } = {}) {
|
|
26
26
|
const { urls } = publisher;
|
|
27
27
|
const priv = visibility === 'private' || visibility === 'direct';
|
|
28
28
|
if (priv) {
|
|
@@ -54,6 +54,7 @@ export async function publishQuestion(publisher, content, { options = [], multip
|
|
|
54
54
|
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
55
55
|
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
56
56
|
if (visibility === 'direct') assertDirectAddressed(content, mentions);
|
|
57
|
+
const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
|
|
57
58
|
const poll = {
|
|
58
59
|
multiple: !!multiple,
|
|
59
60
|
expiresAt: expiresAt || null,
|
|
@@ -62,11 +63,11 @@ export async function publishQuestion(publisher, content, { options = [], multip
|
|
|
62
63
|
votersCount: 0,
|
|
63
64
|
// Resolved once, here: a tally rewrite must not cost a webfinger lookup
|
|
64
65
|
// per vote for people the poll named.
|
|
65
|
-
mentionInboxes: [...new Set(mentions.map(m => m.inbox).filter(Boolean))],
|
|
66
|
+
mentionInboxes: [...new Set([...mentions.map(m => m.inbox), reply?.inbox].filter(Boolean))],
|
|
66
67
|
};
|
|
67
68
|
const question = wire.questionDoc({
|
|
68
69
|
urls, slug, content, published, inReplyTo, attachments: [], mentions,
|
|
69
|
-
visibility, summary: spoilerText,
|
|
70
|
+
visibility, summary: spoilerText, sensitive, also: reply ? [reply.actor] : [],
|
|
70
71
|
container: priv ? urls.privateNotes : urls.notes,
|
|
71
72
|
options: poll.options, multiple: poll.multiple, endTime: poll.expiresAt, votersCount: 0,
|
|
72
73
|
});
|
|
@@ -79,9 +80,12 @@ export async function publishQuestion(publisher, content, { options = [], multip
|
|
|
79
80
|
publisher.store.addStatus({
|
|
80
81
|
noteId: question.id, actor: urls.actor, content: question.content, published,
|
|
81
82
|
kind: 'post', slug, text: content, visibility, poll, inReplyTo,
|
|
83
|
+
...(reply ? { replyActor: reply.actor } : {}),
|
|
82
84
|
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
83
|
-
...(question.
|
|
85
|
+
...(question.sensitive ? { sensitive: true } : {}),
|
|
86
|
+
...rowTags(question),
|
|
84
87
|
});
|
|
88
|
+
if (await publisher.store.commit?.() === false) publisher.log(`poll published but its timeline row was refused: ${question.id}`);
|
|
85
89
|
|
|
86
90
|
const create = publisher._pollActivity('Create', question, wire.createActivityId(question.id));
|
|
87
91
|
await podNotes.writeCreate(publisher.remote, create.id, create);
|
|
@@ -163,8 +167,8 @@ export async function republishPoll(publisher, questionId, { closing = null } =
|
|
|
163
167
|
}));
|
|
164
168
|
const question = wire.questionDoc({
|
|
165
169
|
urls, slug, content: s.text ?? '', published: s.published, inReplyTo: s.inReplyTo,
|
|
166
|
-
attachments: [], mentions,
|
|
167
|
-
visibility: s.visibility || 'public', summary: s.spoiler || null, container,
|
|
170
|
+
attachments: [], mentions, also: s.replyActor ? [s.replyActor] : [],
|
|
171
|
+
visibility: s.visibility || 'public', summary: s.spoiler || null, sensitive: !!s.sensitive, container,
|
|
168
172
|
options: poll.options, multiple: !!poll.multiple, endTime: poll.expiresAt,
|
|
169
173
|
closed: poll.closed, votersCount: poll.votersCount || 0,
|
|
170
174
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import * as wire from '../wire.mjs';
|
|
6
6
|
import * as podNotes from '../../pod/notes.mjs';
|
|
7
7
|
import { readLenient } from '../as2.mjs';
|
|
8
|
+
import { rowContent } from './notes.mjs';
|
|
8
9
|
|
|
9
10
|
const ACCEPT_AP = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
10
11
|
const REBUILD_MAX_PER_RUN = 200;
|
|
@@ -151,13 +152,14 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
|
|
|
151
152
|
if (have.has(id) || removed.has(id)) continue;
|
|
152
153
|
budget--;
|
|
153
154
|
const note = await podNotes.read(publisher.remote, id).catch(() => null);
|
|
154
|
-
|
|
155
|
+
// Any object of ours: a Note, a poll, or whatever a client posted.
|
|
156
|
+
if (!note?.type || note.id !== id || note.attributedTo !== urls.actor) continue;
|
|
155
157
|
const attachments = wire.attachmentsOf(note);
|
|
156
158
|
const mentions = (Array.isArray(note.tag) ? note.tag : [])
|
|
157
159
|
.filter(t => t?.type === 'Mention' && t.href)
|
|
158
160
|
.map(t => ({ href: t.href, name: t.name }));
|
|
159
161
|
recovered.push({
|
|
160
|
-
noteId: note.id, actor: urls.actor, content: note
|
|
162
|
+
noteId: note.id, actor: urls.actor, content: rowContent(note),
|
|
161
163
|
published: note.published || null,
|
|
162
164
|
...(note.inReplyTo ? { inReplyTo: note.inReplyTo } : {}),
|
|
163
165
|
kind: 'post', slug: note.id.slice(urls.notes.length),
|
|
@@ -197,3 +199,26 @@ export async function rebuildStatuses(publisher, { fromNotes = false } = {}) {
|
|
|
197
199
|
dropped: Math.max(0, merged.length - kept.length),
|
|
198
200
|
};
|
|
199
201
|
}
|
|
202
|
+
|
|
203
|
+
// Own posts the outbox records and the statuses index does not.
|
|
204
|
+
//
|
|
205
|
+
// The index row is the last thing a post writes, and it goes by a debounced
|
|
206
|
+
// PUT: a worker killed or a write refused after the post was answered leaves a
|
|
207
|
+
// post that stands on the pod and reached followers but never shows on its
|
|
208
|
+
// author's own timeline. Run when an agent becomes active. Cheap when nothing
|
|
209
|
+
// is wrong — two cached documents compared, no request — and the capped,
|
|
210
|
+
// merge-only rebuild when something is.
|
|
211
|
+
export async function healStatuses(publisher) {
|
|
212
|
+
const { urls, store } = publisher;
|
|
213
|
+
const own = store.read('outbox.json', [])
|
|
214
|
+
.map(i => (typeof i === 'string' ? i : null))
|
|
215
|
+
.filter(id => id && id.startsWith(urls.notes));
|
|
216
|
+
if (!own.length) return { missing: 0, recovered: 0 };
|
|
217
|
+
const have = new Set(store.getStatuses().map(s => s.noteId));
|
|
218
|
+
const removed = new Set(store.read('outbox-removed.json', []).map(r => r.id));
|
|
219
|
+
const missing = own.filter(id => !have.has(id) && !removed.has(id));
|
|
220
|
+
if (!missing.length) return { missing: 0, recovered: 0 };
|
|
221
|
+
const r = await rebuildStatuses(publisher);
|
|
222
|
+
publisher.log(`timeline index healed: ${missing.length} own post(s) were missing, ${r.recovered} recovered`);
|
|
223
|
+
return { missing: missing.length, recovered: r.recovered };
|
|
224
|
+
}
|
package/lib/core/social.mjs
CHANGED
|
@@ -204,6 +204,7 @@ export async function reblog(agent, s) {
|
|
|
204
204
|
// missing entry, a failed status write would let a retry announce twice.
|
|
205
205
|
const updated = agent.store.updateStatus(s.noteId, { reblogged: true, announceActivity: act });
|
|
206
206
|
await agent.publisher.recordOutbox(act);
|
|
207
|
+
if (await agent.store.commit?.() === false) agent.log(`boost sent but its timeline row was refused: ${s.noteId}`);
|
|
207
208
|
return updated;
|
|
208
209
|
}
|
|
209
210
|
|
package/lib/core/store.mjs
CHANGED
|
@@ -74,6 +74,7 @@ export function dropFollower(contacts, actor, why) {
|
|
|
74
74
|
|
|
75
75
|
export class PodStore {
|
|
76
76
|
constructor({ storage = null, log = console.log } = {}) {
|
|
77
|
+
this.lastSkipped = []; // what the last load could not read, as `name (HTTP n)`
|
|
77
78
|
this.storage = storage;
|
|
78
79
|
this.log = log;
|
|
79
80
|
this.cache = new Map(); // name → parsed value
|
|
@@ -146,6 +147,9 @@ export class PodStore {
|
|
|
146
147
|
try { this.cache.set(name, JSON.parse(r.body)); }
|
|
147
148
|
catch (e) { this.log(`state load ${name}: unparsable (${e.message})`); }
|
|
148
149
|
}
|
|
150
|
+
// Kept for /status: a document skipped here is a timeline or a contact
|
|
151
|
+
// list quietly missing, and the owner should be able to see that.
|
|
152
|
+
this.lastSkipped = skipped;
|
|
149
153
|
if (skipped.length) this.log(`state load skipped ${skipped.length}: ${skipped.join(', ')}`);
|
|
150
154
|
this.log(`state loaded: ${this.cache.size} doc(s) from ${this.base} (${fetched} re-fetched)`);
|
|
151
155
|
}
|
|
@@ -410,6 +414,15 @@ export class PodStore {
|
|
|
410
414
|
// — not from the pod, not from the RDF. Unverified entries are dropped first
|
|
411
415
|
// now, so a flood can only ever evict itself, and a stranger's genuine
|
|
412
416
|
// favourite still shows up, which is what recording them at all is for.
|
|
417
|
+
// What an Undo unsays: the favourite or boost it names, matched by a
|
|
418
|
+
// predicate on what was recorded. Returns how many went.
|
|
419
|
+
removeNotifications(pred) {
|
|
420
|
+
const all = this.getNotifications();
|
|
421
|
+
const kept = all.filter(n => !pred(n));
|
|
422
|
+
if (kept.length !== all.length) this.write('notifications.json', kept);
|
|
423
|
+
return all.length - kept.length;
|
|
424
|
+
}
|
|
425
|
+
|
|
413
426
|
addNotification(n) {
|
|
414
427
|
const all = this.getNotifications();
|
|
415
428
|
const id = crypto.createHash('sha256').update(JSON.stringify(n)).digest('hex').slice(0, 16);
|
package/lib/core/wire.mjs
CHANGED
|
@@ -52,11 +52,14 @@ export function hostMeta(base) {
|
|
|
52
52
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n <Link rel="lrdd" template="${base}.well-known/webfinger?resource={uri}"/>\n</XRD>\n`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function jrd({ handle, host, actor, aliases = [] }) {
|
|
55
|
+
export function jrd({ handle, host, actor, aliases = [], page = null }) {
|
|
56
56
|
return {
|
|
57
57
|
subject: `acct:${handle}@${host}`,
|
|
58
58
|
...(aliases.length ? { aliases } : {}),
|
|
59
|
-
links: [
|
|
59
|
+
links: [
|
|
60
|
+
{ rel: 'self', type: 'application/activity+json', href: actor },
|
|
61
|
+
...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
|
|
62
|
+
],
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -372,7 +375,7 @@ export function outboxPage(outboxId, n, items) {
|
|
|
372
375
|
id: outboxPageId(outboxId, n),
|
|
373
376
|
type: 'OrderedCollectionPage',
|
|
374
377
|
partOf: outboxId,
|
|
375
|
-
orderedItems: items,
|
|
378
|
+
orderedItems: items.map(outboxWireItem),
|
|
376
379
|
...(n > 1 ? { next: outboxPageId(outboxId, n - 1) } : {}),
|
|
377
380
|
};
|
|
378
381
|
}
|
|
@@ -394,6 +397,13 @@ export function outboxPages(outbox) {
|
|
|
394
397
|
|
|
395
398
|
export const outboxItemId = (i) => (typeof i === 'string' ? i : i?.id || null);
|
|
396
399
|
|
|
400
|
+
// The outbox lists activities. A post is recorded by its object id, which is
|
|
401
|
+
// what every local reader keys on, and published as the Create beside it — a
|
|
402
|
+
// document of its own. An Announce is recorded and published whole.
|
|
403
|
+
export const outboxWireItem = (i) => (typeof i === 'string' ? createActivityId(i) : i);
|
|
404
|
+
export const outboxLocalItem = (i) =>
|
|
405
|
+
(typeof i === 'string' && i.endsWith('-create') ? i.slice(0, -'-create'.length) : i);
|
|
406
|
+
|
|
397
407
|
// Assign entries to pages, and KEEP the assignment.
|
|
398
408
|
//
|
|
399
409
|
// Deriving the pages from position — which is what outboxPages does — makes an
|
|
@@ -589,10 +599,37 @@ export function mentionsIn(text) {
|
|
|
589
599
|
return [...new Set(String(text).match(MENTION_RE) || [])].map(m => m.slice(1));
|
|
590
600
|
}
|
|
591
601
|
|
|
602
|
+
// #word in the text: letters, digits and underscores with at least one
|
|
603
|
+
// letter, not glued to a word or a path (so a URL's fragment is not a tag).
|
|
604
|
+
export const HASHTAG_RE = /(^|[^\/\w)])#([\p{L}\p{N}_]*[\p{L}_][\p{L}\p{N}_]*)/gu;
|
|
605
|
+
|
|
606
|
+
export function hashtagsIn(text) {
|
|
607
|
+
const seen = new Map();
|
|
608
|
+
for (const m of String(text).matchAll(HASHTAG_RE)) {
|
|
609
|
+
const key = m[2].toLowerCase();
|
|
610
|
+
if (!seen.has(key)) seen.set(key, m[2]);
|
|
611
|
+
}
|
|
612
|
+
return [...seen.values()];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Where a tag's link points: this actor's own page, asked for the tag. A
|
|
616
|
+
// Hashtag's href must be a URL; the name is what servers file it under.
|
|
617
|
+
export function tagHref(urls, name) {
|
|
618
|
+
return urls.profileHtml + '?tag=' + encodeURIComponent(String(name).toLowerCase());
|
|
619
|
+
}
|
|
620
|
+
|
|
592
621
|
// Escape first, then linkify: escaping cannot introduce an @handle, and the
|
|
593
622
|
// anchors must survive it. A mention nobody could resolve stays plain text.
|
|
594
|
-
export function contentHtml(text, mentions = []) {
|
|
623
|
+
export function contentHtml(text, mentions = [], hashtags = []) {
|
|
595
624
|
let html = String(text).replace(/[&<>]/g, c => HTML_ESCAPES[c]);
|
|
625
|
+
if (hashtags.length) {
|
|
626
|
+
const byName = new Map(hashtags.filter(h => h?.name && h?.href).map(h => [h.name.toLowerCase(), h.href]));
|
|
627
|
+
html = html.replace(HASHTAG_RE, (all, lead, name) => {
|
|
628
|
+
const href = byName.get(name.toLowerCase());
|
|
629
|
+
if (!href) return all;
|
|
630
|
+
return `${lead}<a href="${String(href).replace(/[&<>"]/g, c => (HTML_ESCAPES[c] || '"'))}" class="mention hashtag" rel="tag">#<span>${name}</span></a>`;
|
|
631
|
+
});
|
|
632
|
+
}
|
|
596
633
|
for (const m of mentions) {
|
|
597
634
|
if (!m?.handle || !m?.actor) continue;
|
|
598
635
|
// The anchor is for PEOPLE — the mentioned actor's advertised page when it
|
|
@@ -605,36 +642,50 @@ export function contentHtml(text, mentions = []) {
|
|
|
605
642
|
return '<p>' + html.replace(/\n+/g, '</p><p>') + '</p>';
|
|
606
643
|
}
|
|
607
644
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
// direct post) and will not notify anyone it does not find there or in the
|
|
616
|
-
// tags.
|
|
617
|
-
const addressing = {
|
|
645
|
+
// Mastodon's four shapes. Public and unlisted are world-readable; private
|
|
646
|
+
// (followers-only) and direct carry no Public address at all and belong in
|
|
647
|
+
// the owner-only container. Mastodon addresses mentions in cc (to, for a
|
|
648
|
+
// direct post) and will not notify anyone it does not find there or in the
|
|
649
|
+
// tags.
|
|
650
|
+
export function addressing(urls, visibility, who = []) {
|
|
651
|
+
return {
|
|
618
652
|
public: { to: [PUBLIC], cc: [urls.followers, ...who] },
|
|
619
653
|
unlisted: { to: [urls.followers], cc: [PUBLIC, ...who] },
|
|
620
654
|
private: { to: [urls.followers], cc: who },
|
|
621
655
|
direct: { to: who, cc: [] },
|
|
622
656
|
}[visibility] || { to: [PUBLIC], cc: [urls.followers, ...who] };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// `also`: actors addressed without being mentioned — the parent's author on
|
|
660
|
+
// a reply. In cc but not in tag, so their server receives the post and does
|
|
661
|
+
// not notify them. A direct post names its audience in the text alone.
|
|
662
|
+
export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
|
|
663
|
+
visibility = 'public', summary = null, sensitive = false, updated = null, container = null, also = [] }) {
|
|
664
|
+
const id = (container || urls.notes) + slug;
|
|
665
|
+
const who = mentions.map(m => m.actor);
|
|
666
|
+
const extra = visibility === 'direct' ? [] : also.filter(a => a && !who.includes(a));
|
|
667
|
+
const addressed = addressing(urls, visibility, [...who, ...extra]);
|
|
668
|
+
const hashtags = hashtagsIn(content).map(name => ({ name, href: tagHref(urls, name) }));
|
|
623
669
|
const note = {
|
|
624
670
|
'@context': AS_CTX,
|
|
625
671
|
id, type: 'Note',
|
|
626
672
|
attributedTo: urls.actor,
|
|
627
|
-
content: contentHtml(content, mentions),
|
|
673
|
+
content: contentHtml(content, mentions, hashtags),
|
|
628
674
|
published,
|
|
629
|
-
to:
|
|
630
|
-
cc:
|
|
675
|
+
to: addressed.to,
|
|
676
|
+
cc: addressed.cc,
|
|
631
677
|
replies: repliesId(id),
|
|
632
678
|
};
|
|
633
679
|
if (summary) note.summary = summary; // the content warning
|
|
680
|
+
// A content warning is what a receiver hides behind; media marked
|
|
681
|
+
// sensitive without one is hidden the same way.
|
|
682
|
+
if (summary || sensitive) note.sensitive = true;
|
|
634
683
|
if (updated) note.updated = updated; // an edit's own stamp
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
684
|
+
const tag = [
|
|
685
|
+
...mentions.map(m => ({ type: 'Mention', href: m.actor, name: '@' + m.handle })),
|
|
686
|
+
...hashtags.map(h => ({ type: 'Hashtag', href: h.href, name: '#' + h.name })),
|
|
687
|
+
];
|
|
688
|
+
if (tag.length) note.tag = tag;
|
|
638
689
|
if (inReplyTo) note.inReplyTo = inReplyTo;
|
|
639
690
|
if (attachments?.length) {
|
|
640
691
|
note.attachment = attachments.map(a => ({
|
|
@@ -130,8 +130,8 @@ export function securityHeaders(auth, isHtml) {
|
|
|
130
130
|
return h;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
export function sendJson(res, status, obj, auth) {
|
|
134
|
-
res.writeHead(status, { 'content-type':
|
|
133
|
+
export function sendJson(res, status, obj, auth, contentType = 'application/json') {
|
|
134
|
+
res.writeHead(status, { 'content-type': contentType, ...securityHeaders(auth, false) });
|
|
135
135
|
res.end(JSON.stringify(obj) + '\n');
|
|
136
136
|
}
|
|
137
137
|
|
|
@@ -181,6 +181,14 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
181
181
|
const scheme = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
|
|
182
182
|
return json(res, 200, masto.authorizationServerMetadata(`${scheme}://${req.headers.host}${mount}`));
|
|
183
183
|
}
|
|
184
|
+
// The short address a person types: /@handle opens the profile page.
|
|
185
|
+
const at = /^\/@([^/]+)$/u.exec(p);
|
|
186
|
+
const ownHandle = handle || agent.store?.getConfig?.()?.handle || '';
|
|
187
|
+
if (at && ownHandle && agent.publisher?.urls?.profileHtml
|
|
188
|
+
&& decodeURIComponent(at[1]).toLowerCase() === String(ownHandle).toLowerCase()) {
|
|
189
|
+
res.writeHead(302, { location: agent.publisher.urls.profileHtml, 'cache-control': 'no-store' });
|
|
190
|
+
res.end(); return;
|
|
191
|
+
}
|
|
184
192
|
if (atDoor && gate(req, res)) return;
|
|
185
193
|
if (p === '/api/v1/streaming/health') {
|
|
186
194
|
res.writeHead(200, { 'content-type': 'text/plain' }); res.end('OK'); return;
|
|
@@ -191,10 +199,11 @@ export function buildAdminSurface({ agent, gate, allowed, log = console.log,
|
|
|
191
199
|
`${req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'}://${req.headers.host}${mount}/nodeinfo/2.0`));
|
|
192
200
|
}
|
|
193
201
|
if (p === '/nodeinfo/2.0') {
|
|
194
|
-
|
|
202
|
+
// The schema's own media type, as the NodeInfo spec names it.
|
|
203
|
+
return sendJson(res, 200, nodeinfoDoc({
|
|
195
204
|
version: AGENT_VERSION,
|
|
196
205
|
localPosts: agent.store.countStatuses('post'),
|
|
197
|
-
}));
|
|
206
|
+
}), allowed, 'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"');
|
|
198
207
|
}
|
|
199
208
|
// A group serves the client too, as of 2026-08-01. It was withheld on the
|
|
200
209
|
// reasoning that a group has no timeline a human reads — which was wrong
|