fedipod 1.6.0 → 1.8.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.
Files changed (52) hide show
  1. package/README.md +9 -3
  2. package/device-agent.md +99 -0
  3. package/gateway.md +16 -0
  4. package/groups.md +1 -1
  5. package/gui.md +1 -1
  6. package/lib/client/c2s.mjs +95 -61
  7. package/lib/client/masto/index.mjs +7 -1
  8. package/lib/client/masto/timelines.mjs +1 -1
  9. package/lib/client/oidc-auth.mjs +5 -3
  10. package/lib/core/contexts/anno.json +126 -0
  11. package/lib/core/contexts/index.mjs +4 -0
  12. package/lib/core/contexts/map.json +2 -1
  13. package/lib/core/intake/index.mjs +32 -5
  14. package/lib/core/publisher/index.mjs +15 -1
  15. package/lib/core/publisher/notes.mjs +84 -2
  16. package/lib/core/publisher/questions.mjs +1 -0
  17. package/lib/core/publisher/restore.mjs +27 -2
  18. package/lib/core/social.mjs +1 -0
  19. package/lib/core/store.mjs +4 -0
  20. package/lib/core/wire.mjs +20 -13
  21. package/lib/device/admin/routes/gateway.mjs +1 -1
  22. package/lib/device/admin/surface.mjs +25 -17
  23. package/lib/device/cli/commands/setup.mjs +9 -1
  24. package/lib/device/setup.mjs +6 -0
  25. package/lib/gateway/front-core.mjs +66 -12
  26. package/lib/gateway/gateway-core.mjs +40 -0
  27. package/lib/pod/actor.mjs +2 -2
  28. package/lib/pod/root.mjs +11 -0
  29. package/lib/pod/transport.mjs +9 -3
  30. package/lib/server/embed.mjs +23 -6
  31. package/package.json +2 -2
  32. package/run-agent.mjs +10 -1
  33. package/scripts/refresh-contexts.mjs +5 -0
  34. package/vendor/gate.cjs +5 -2
  35. package/web/admin/index.html +2 -0
  36. package/web/admin/upkeep.js +9 -1
  37. package/web/app/README.md +1 -1
  38. package/web/app/agent.mjs +11 -1
  39. package/web/app/boot.mjs +49 -10
  40. package/web/app/dist/boot.js +89 -37
  41. package/web/app/dist/boot.js.map +3 -3
  42. package/web/app/dist/sw.js +1326 -662
  43. package/web/app/dist/sw.js.map +4 -4
  44. package/web/app/index.html +16 -6
  45. package/web/app/signup.mjs +3 -1
  46. package/web/app/site/admin/index.html +2 -0
  47. package/web/app/site/admin/upkeep.js +9 -1
  48. package/web/app/site/boot.js +89 -37
  49. package/web/app/site/index.html +16 -6
  50. package/web/app/site/sw.js +1326 -662
  51. package/web/front/#new-account.html# +0 -43
  52. package/web/front/new-account.html~ +0 -50
@@ -0,0 +1,126 @@
1
+ {
2
+ "@context": {
3
+ "oa": "http://www.w3.org/ns/oa#",
4
+ "dc": "http://purl.org/dc/elements/1.1/",
5
+ "dcterms": "http://purl.org/dc/terms/",
6
+ "dctypes": "http://purl.org/dc/dcmitype/",
7
+ "foaf": "http://xmlns.com/foaf/0.1/",
8
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
9
+ "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
10
+ "skos": "http://www.w3.org/2004/02/skos/core#",
11
+ "xsd": "http://www.w3.org/2001/XMLSchema#",
12
+ "iana": "http://www.iana.org/assignments/relation/",
13
+ "owl": "http://www.w3.org/2002/07/owl#",
14
+ "as": "http://www.w3.org/ns/activitystreams#",
15
+ "schema": "http://schema.org/",
16
+
17
+ "id": {"@type": "@id", "@id": "@id"},
18
+ "type": {"@type": "@id", "@id": "@type"},
19
+
20
+ "Annotation": "oa:Annotation",
21
+ "Dataset": "dctypes:Dataset",
22
+ "Image": "dctypes:StillImage",
23
+ "Video": "dctypes:MovingImage",
24
+ "Audio": "dctypes:Sound",
25
+ "Text": "dctypes:Text",
26
+ "TextualBody": "oa:TextualBody",
27
+ "ResourceSelection": "oa:ResourceSelection",
28
+ "SpecificResource": "oa:SpecificResource",
29
+ "FragmentSelector": "oa:FragmentSelector",
30
+ "CssSelector": "oa:CssSelector",
31
+ "XPathSelector": "oa:XPathSelector",
32
+ "TextQuoteSelector": "oa:TextQuoteSelector",
33
+ "TextPositionSelector": "oa:TextPositionSelector",
34
+ "DataPositionSelector": "oa:DataPositionSelector",
35
+ "SvgSelector": "oa:SvgSelector",
36
+ "RangeSelector": "oa:RangeSelector",
37
+ "TimeState": "oa:TimeState",
38
+ "HttpRequestState": "oa:HttpRequestState",
39
+ "CssStylesheet": "oa:CssStyle",
40
+ "Choice": "oa:Choice",
41
+ "Person": "foaf:Person",
42
+ "Software": "as:Application",
43
+ "Organization": "foaf:Organization",
44
+ "AnnotationCollection": "as:OrderedCollection",
45
+ "AnnotationPage": "as:OrderedCollectionPage",
46
+ "Audience": "schema:Audience",
47
+
48
+ "Motivation": "oa:Motivation",
49
+ "bookmarking": "oa:bookmarking",
50
+ "classifying": "oa:classifying",
51
+ "commenting": "oa:commenting",
52
+ "describing": "oa:describing",
53
+ "editing": "oa:editing",
54
+ "highlighting": "oa:highlighting",
55
+ "identifying": "oa:identifying",
56
+ "linking": "oa:linking",
57
+ "moderating": "oa:moderating",
58
+ "questioning": "oa:questioning",
59
+ "replying": "oa:replying",
60
+ "reviewing": "oa:reviewing",
61
+ "assessing": "oa:assessing",
62
+ "tagging": "oa:tagging",
63
+
64
+ "auto": "oa:autoDirection",
65
+ "ltr": "oa:ltrDirection",
66
+ "rtl": "oa:rtlDirection",
67
+
68
+ "body": {"@type": "@id", "@id": "oa:hasBody"},
69
+ "target": {"@type": "@id", "@id": "oa:hasTarget"},
70
+ "source": {"@type": "@id", "@id": "oa:hasSource"},
71
+ "selector": {"@type": "@id", "@id": "oa:hasSelector"},
72
+ "state": {"@type": "@id", "@id": "oa:hasState"},
73
+ "scope": {"@type": "@id", "@id": "oa:hasScope"},
74
+ "refinedBy": {"@type": "@id", "@id": "oa:refinedBy"},
75
+ "startSelector": {"@type": "@id", "@id": "oa:hasStartSelector"},
76
+ "endSelector": {"@type": "@id", "@id": "oa:hasEndSelector"},
77
+ "renderedVia": {"@type": "@id", "@id": "oa:renderedVia"},
78
+ "creator": {"@type": "@id", "@id": "dcterms:creator"},
79
+ "generator": {"@type": "@id", "@id": "as:generator"},
80
+ "rights": {"@type": "@id", "@id": "dcterms:rights"},
81
+ "homepage": {"@type": "@id", "@id": "foaf:homepage"},
82
+ "via": {"@type": "@id", "@id": "oa:via"},
83
+ "canonical": {"@type": "@id", "@id": "oa:canonical"},
84
+ "stylesheet": {"@type": "@id", "@id": "oa:styledBy"},
85
+ "cached": {"@type": "@id", "@id": "oa:cachedSource"},
86
+ "conformsTo": {"@type": "@id", "@id": "dcterms:conformsTo"},
87
+ "items": {"@type": "@id", "@id": "as:items", "@container": "@list"},
88
+ "partOf": {"@type": "@id", "@id": "as:partOf"},
89
+ "first": {"@type": "@id", "@id": "as:first"},
90
+ "last": {"@type": "@id", "@id": "as:last"},
91
+ "next": {"@type": "@id", "@id": "as:next"},
92
+ "prev": {"@type": "@id", "@id": "as:prev"},
93
+ "audience": {"@type": "@id", "@id": "schema:audience"},
94
+ "motivation": {"@type": "@vocab", "@id": "oa:motivatedBy"},
95
+ "purpose": {"@type": "@vocab", "@id": "oa:hasPurpose"},
96
+ "textDirection": {"@type": "@vocab", "@id": "oa:textDirection"},
97
+
98
+ "accessibility": "schema:accessibilityFeature",
99
+ "bodyValue": "oa:bodyValue",
100
+ "format": "dc:format",
101
+ "language": "dc:language",
102
+ "processingLanguage": "oa:processingLanguage",
103
+ "value": "rdf:value",
104
+ "exact": "oa:exact",
105
+ "prefix": "oa:prefix",
106
+ "suffix": "oa:suffix",
107
+ "styleClass": "oa:styleClass",
108
+ "name": "foaf:name",
109
+ "email": "foaf:mbox",
110
+ "email_sha1": "foaf:mbox_sha1sum",
111
+ "nickname": "foaf:nick",
112
+ "label": "rdfs:label",
113
+
114
+ "created": {"@id": "dcterms:created", "@type": "xsd:dateTime"},
115
+ "modified": {"@id": "dcterms:modified", "@type": "xsd:dateTime"},
116
+ "generated": {"@id": "dcterms:issued", "@type": "xsd:dateTime"},
117
+ "sourceDate": {"@id": "oa:sourceDate", "@type": "xsd:dateTime"},
118
+ "sourceDateStart": {"@id": "oa:sourceDateStart", "@type": "xsd:dateTime"},
119
+ "sourceDateEnd": {"@id": "oa:sourceDateEnd", "@type": "xsd:dateTime"},
120
+
121
+ "start": {"@id": "oa:start", "@type": "xsd:nonNegativeInteger"},
122
+ "end": {"@id": "oa:end", "@type": "xsd:nonNegativeInteger"},
123
+ "total": {"@id": "as:totalItems", "@type": "xsd:nonNegativeInteger"},
124
+ "startIndex": {"@id": "as:startIndex", "@type": "xsd:nonNegativeInteger"}
125
+ }
126
+ }
@@ -25,6 +25,9 @@ import ctx10 from './fep-5711.json' with { type: 'json' };
25
25
  import ctx11 from './join-lemmy.json' with { type: 'json' };
26
26
  import ctx12 from './joinmastodon.json' with { type: 'json' };
27
27
  import ctx13 from './miscellany.json' with { type: 'json' };
28
+ // Not from fedify: the W3C Web Annotation context, which a client such as
29
+ // dokieli names on the annotations it posts to the outbox.
30
+ import ctx14 from './anno.json' with { type: 'json' };
28
31
 
29
32
  /** URL → the context document itself. Nothing outside this map is ever resolved. */
30
33
  export const CONTEXTS = {
@@ -42,4 +45,5 @@ export const CONTEXTS = {
42
45
  'https://join-lemmy.org/context.json': ctx11,
43
46
  'http://joinmastodon.org/ns': ctx12,
44
47
  'https://purl.archive.org/miscellany': ctx13,
48
+ 'http://www.w3.org/ns/anno.jsonld': ctx14,
45
49
  };
@@ -12,5 +12,6 @@
12
12
  "https://w3id.org/fep/5711": "fep-5711.json",
13
13
  "https://join-lemmy.org/context.json": "join-lemmy.json",
14
14
  "http://joinmastodon.org/ns": "joinmastodon.json",
15
- "https://purl.archive.org/miscellany": "miscellany.json"
15
+ "https://purl.archive.org/miscellany": "miscellany.json",
16
+ "http://www.w3.org/ns/anno.jsonld": "anno.json"
16
17
  }
@@ -59,8 +59,10 @@ const MAX_ITEM_ATTEMPTS = 5;
59
59
  const MAX_ITEMS_PER_DRAIN = 50;
60
60
 
61
61
  export class Intake {
62
- constructor({ config, urls, remote, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null }) {
63
- Object.assign(this, { config, urls, remote, store, deliverer, publisher, log, lease, archive, push, pollSeconds });
62
+ // `ownerPost(activity, { raw, slug })` is the client-to-server dispatcher,
63
+ // for an item the Gateway's outbox door took on the owner's behalf.
64
+ constructor({ config, urls, remote, store, deliverer, publisher, log = console.log, lease = null, archive = null, push = true, pollSeconds = null, ownerPost = null }) {
65
+ Object.assign(this, { config, urls, remote, store, deliverer, publisher, log, lease, archive, push, pollSeconds, ownerPost });
64
66
  this.serial = Date.now();
65
67
  this.stopped = false;
66
68
  // (attempt counts are kept in pod state — see _bumpAttempt)
@@ -429,9 +431,14 @@ export class Intake {
429
431
  // receipt reads as null, which is exactly today's unverified behavior.
430
432
  const receipt = activity ? await this._readReceipt(url) : null;
431
433
  if (activity && this.gatewaySecret()) this._bumpGatewayStat(!!receipt?.verified);
432
- const rejection = activity ? await this.handle(activity, receipt) : 'unparsable JSON';
433
- if (!rejection && raw) await this._archive(url, raw, activity);
434
- if (!rejection) await this._maybeForward(activity); // §7.1.2, only what we accepted
434
+ // The owner's own post, taken at the outbox door: not mail to read
435
+ // but a write to make. Never archived or forwarded as if received.
436
+ const owned = activity && this.isOwnerPost(receipt);
437
+ const rejection = !activity ? 'unparsable JSON'
438
+ : owned ? await this.ownerPostFrom(activity, raw, receipt)
439
+ : await this.handle(activity, receipt);
440
+ if (!rejection && raw && !owned) await this._archive(url, raw, activity);
441
+ if (!rejection && !owned) await this._maybeForward(activity); // §7.1.2, only what we accepted
435
442
  if (rejection) {
436
443
  this.store.addDeadLetter({
437
444
  inboxUrl: url, reason: rejection, activity: trimActivity(activity),
@@ -594,6 +601,26 @@ export class Intake {
594
601
  gatewaySecret(...a) { return verify.gatewaySecret(this, ...a); }
595
602
  _bumpGatewayStat(...a) { return verify.bumpGatewayStat(this, ...a); }
596
603
  _readReceipt(...a) { return verify.readReceipt(this, ...a); }
604
+
605
+ // A receipt the door stamped `c2s` for THIS actor, and only a receipt whose
606
+ // HMAC verified (readReceipt returns nothing else). A stranger appending an
607
+ // item that claims to be ours has no such receipt and is read as mail.
608
+ isOwnerPost(receipt) {
609
+ return !!(this.ownerPost && receipt?.verified && receipt.method === 'c2s' && receipt.actor === this.urls.actor);
610
+ }
611
+
612
+ // Publish what the owner posted at the door. The bytes as the client wrote
613
+ // them go to the dispatcher (an object stored as sent must be the client's
614
+ // document, not our reading of it); the graph is what it decides from.
615
+ // Returns null when published, else the reason it was not — a dead letter.
616
+ async ownerPostFrom(activity, raw, receipt) {
617
+ let asSent = null;
618
+ try { asSent = raw ? JSON.parse(raw) : null; } catch { asSent = null; }
619
+ const r = await this.ownerPost(activity, { raw: asSent, slug: receipt.slug || null });
620
+ if (!r || r.status >= 300) return `owner post refused (${r?.status || '?'}): ${r?.body?.error || ''}`;
621
+ this.log(`owner post from the outbox door published: ${r.body?.object || r.body?.id || activity.type}`);
622
+ return null;
623
+ }
597
624
  receiptVouchesFor(...a) { return verify.receiptVouchesFor(this, ...a); }
598
625
  isGone(...a) { return verify.isGone(this, ...a); }
599
626
 
@@ -76,6 +76,15 @@ export class Publisher {
76
76
  // republish button. Without it, an actor lost from the pod would match the
77
77
  // digest, be skipped, and leave the agent reporting success while nobody can
78
78
  // resolve it.
79
+ // The Gateway's outbox door for this account, when a Gateway is attached:
80
+ // beside its inbox door, or under the fronted actor.
81
+ gatewayOutbox() {
82
+ const gw = this.config.gateway;
83
+ if (!(gw && gw.url && gw.mode && gw.mode !== 'off')) return null;
84
+ if (gw.frontActor) return String(gw.frontActor).replace(/ap\/actor\/?$/u, 'ap/outbox');
85
+ return String(gw.url).replace(/ap\/inbox\/?$/u, 'ap/outbox');
86
+ }
87
+
79
88
  async publishProfile({ force = false } = {}) {
80
89
  const { urls } = this;
81
90
  const host = new URL(urls.base).host;
@@ -105,7 +114,8 @@ export class Publisher {
105
114
  inbox: gwActive ? gw.url : null,
106
115
  // The agent's own outbox endpoint, where it is reachable: a client
107
116
  // following the actor must arrive somewhere that will take a write.
108
- outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : null,
117
+ // Otherwise the Gateway's outbox door, when one is attached.
118
+ outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
109
119
  // How a client-to-server client finds the way in with nothing configured
110
120
  // by hand. Advertised only where the surface is publicly reachable.
111
121
  oauthAuthorize: this.clientOrigin ? `${this.clientOrigin}oauth/authorize` : null,
@@ -163,6 +173,8 @@ export class Publisher {
163
173
  actorUrl: urls.actor,
164
174
  accountName: `@${this.config.handle}@${host}`,
165
175
  kind: this.config.kind,
176
+ // Where a Solid client posts: dokieli reads `as:outbox` off the WebID.
177
+ outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
166
178
  });
167
179
  if (wrote) this.log('WebID profile now lists the actor as a foaf:account');
168
180
  } catch (e) {
@@ -400,6 +412,7 @@ export class Publisher {
400
412
  reconcileFollowers(...a) { return restore.reconcileFollowers(this, ...a); }
401
413
  reconcileOutbox(...a) { return restore.reconcileOutbox(this, ...a); }
402
414
  rebuildStatuses(...a) { return restore.rebuildStatuses(this, ...a); }
415
+ healStatuses(...a) { return restore.healStatuses(this, ...a); }
403
416
 
404
417
  // notes.mjs
405
418
  ensureMediaContainer(...a) { return notes.ensureMediaContainer(this, ...a); }
@@ -408,6 +421,7 @@ export class Publisher {
408
421
  privateReady(...a) { return notes.privateReady(this, ...a); }
409
422
  _mentionsFor(...a) { return notes.mentionsFor(this, ...a); }
410
423
  publishNote(...a) { return notes.publishNote(this, ...a); }
424
+ publishObject(...a) { return notes.publishObject(this, ...a); }
411
425
  updateNote(...a) { return notes.updateNote(this, ...a); }
412
426
 
413
427
  // questions.mjs
@@ -99,7 +99,28 @@ export function assertDirectAddressed(content, mentions) {
99
99
  throw e;
100
100
  }
101
101
 
102
- export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null } = {}) {
102
+ // A client may name the document it is creating (the Slug of a client-to-server
103
+ // POST). Only a plain name is taken, and only when nothing is there already;
104
+ // otherwise the agent mints one as it always has.
105
+ const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
106
+ export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
107
+ async function slugFor(publisher, container, wanted, published) {
108
+ const name = safeSlug(wanted);
109
+ if (name && !(await podNotes.read(publisher.remote, container + name).catch(() => null))) return name;
110
+ return published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
111
+ }
112
+
113
+ // What the author's own timeline shows for an object: its content, else its
114
+ // text under one of the names other vocabularies use, else a link to it.
115
+ export function rowContent(obj) {
116
+ if (typeof obj?.content === 'string' && obj.content.trim()) return wire.sanitizeHtml(obj.content);
117
+ const plain = [obj?.bodyValue, obj?.name, obj?.summary].find((v) => typeof v === 'string' && v.trim());
118
+ if (plain) return wire.contentHtml(plain);
119
+ const esc = (v) => String(v).replace(/[&<>"]/gu, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
120
+ return `<p><a href="${esc(obj?.id || '')}">${esc(obj?.type || 'object')}</a></p>`;
121
+ }
122
+
123
+ export async function publishNote(publisher, content, { inReplyTo, attachments, visibility = 'public', spoilerText = null, slug: wanted = null } = {}) {
103
124
  const { urls } = publisher;
104
125
  const priv = visibility === 'private' || visibility === 'direct';
105
126
  if (priv) {
@@ -107,7 +128,7 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
107
128
  if (ready !== true) throw new Error(ready);
108
129
  }
109
130
  const published = new Date().toISOString();
110
- const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
131
+ const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
111
132
  const mentions = await publisher._mentionsFor(content, inReplyTo);
112
133
  if (visibility === 'direct') assertDirectAddressed(content, mentions);
113
134
  const note = wire.noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions,
@@ -127,6 +148,11 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
127
148
  ...(attachments?.length ? { attachments } : {}),
128
149
  ...(note.tag?.length ? { mentions: note.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
129
150
  });
151
+ // The row is what the author's own timeline reads. Land it before answering
152
+ // — the same PUT the debounce would send in 300 ms — so a worker killed or a
153
+ // write refused after the answer cannot leave a post that stands on the pod
154
+ // and reached followers but never shows to its author.
155
+ if (await publisher.store.commit?.() === false) publisher.log(`post published but its timeline row was refused: ${note.id}`);
130
156
 
131
157
  const create = wire.createActivity(note, urls);
132
158
  // Published as its own document: a group that carries this post wraps the
@@ -164,6 +190,62 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
164
190
  // edit's own stamp. The pod documents are overwritten in place — the Create
165
191
  // too, so a group's Announce resolves to the edited text — and an Update
166
192
  // goes everywhere the Create went.
193
+ // Any object as a post: what a client-to-server Create carries when it is not
194
+ // a Note or a Question — a Web Annotation, say. Stored as sent, with its own
195
+ // context, under this actor; the Create around it is what followers receive.
196
+ // Servers that know the type show it; the rest ignore it, which is the
197
+ // expected outcome.
198
+ //
199
+ // An object already living on this pod (an id under it) is not copied: the
200
+ // Create names it where it is. Anything else is written into the notes
201
+ // container under the client's slug when it is free, else a minted one.
202
+ export async function publishObject(publisher, object, { visibility = 'public', slug: wanted = null } = {}) {
203
+ const { urls } = publisher;
204
+ const priv = visibility === 'private' || visibility === 'direct';
205
+ if (priv) {
206
+ const ready = await publisher.privateReady();
207
+ if (ready !== true) throw new Error(ready);
208
+ }
209
+ const published = new Date().toISOString();
210
+ const container = priv ? urls.privateNotes : urls.notes;
211
+ const pod = publisher.config?.remotePod || urls.base;
212
+ const ownId = typeof object.id === 'string' && /^https?:\/\//u.test(object.id) && object.id.startsWith(pod)
213
+ ? object.id : null;
214
+ const name = await slugFor(publisher, container, wanted, published);
215
+ const addressed = wire.addressing(urls, visibility);
216
+ let doc = null;
217
+ const id = ownId || container + name;
218
+ if (!ownId) {
219
+ doc = { ...object, id, attributedTo: urls.actor, published: object.published || published, to: addressed.to, cc: addressed.cc };
220
+ if (!doc['@context']) doc['@context'] = wire.AS_CTX;
221
+ if (typeof doc.content === 'string') doc.content = wire.sanitizeHtml(doc.content);
222
+ await podNotes.write(publisher.remote, id, doc);
223
+ }
224
+ if (!priv) await publisher.recordOutbox(id);
225
+ const row = doc || object;
226
+ publisher.store.addStatus({
227
+ noteId: id, actor: urls.actor, content: rowContent(row), published: row.published || published,
228
+ kind: 'post', slug: name, visibility,
229
+ });
230
+ if (await publisher.store.commit?.() === false) publisher.log(`object published but its timeline row was refused: ${id}`);
231
+
232
+ // The Create sits beside the object; for an object living elsewhere on the
233
+ // pod it sits in the notes container, whose ACL is public Read.
234
+ const createId = doc ? wire.createActivityId(id) : wire.createActivityId(container + name);
235
+ const create = {
236
+ '@context': wire.AS_CTX, id: createId, type: 'Create', actor: urls.actor,
237
+ published: row.published || published, to: addressed.to, cc: addressed.cc,
238
+ object: doc || id,
239
+ };
240
+ await podNotes.writeCreate(publisher.remote, create.id, create);
241
+ const contacts = publisher.store.getContacts();
242
+ const inboxes = visibility === 'direct' ? []
243
+ : [...new Set(contacts.followers.map((f) => f.sharedInbox || f.inbox).filter(Boolean))];
244
+ await publisher.deliverer.deliverToAll(inboxes, create);
245
+ publisher.log(`${row.type || 'object'} published: ${id} → ${inboxes.length} inbox(es)`);
246
+ return { id, createId, copied: !ownId };
247
+ }
248
+
167
249
  export async function updateNote(publisher, s, { content, spoilerText = null, attachments = null } = {}) {
168
250
  const { urls } = publisher;
169
251
  const updated = new Date().toISOString();
@@ -82,6 +82,7 @@ export async function publishQuestion(publisher, content, { options = [], multip
82
82
  ...(spoilerText ? { spoiler: spoilerText } : {}),
83
83
  ...(question.tag?.length ? { mentions: question.tag.map(t => ({ href: t.href, name: t.name })) } : {}),
84
84
  });
85
+ if (await publisher.store.commit?.() === false) publisher.log(`poll published but its timeline row was refused: ${question.id}`);
85
86
 
86
87
  const create = publisher._pollActivity('Create', question, wire.createActivityId(question.id));
87
88
  await podNotes.writeCreate(publisher.remote, create.id, create);
@@ -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
- if (note?.type !== 'Note' || note.id !== id || note.attributedTo !== urls.actor) continue;
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.content || '',
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
+ }
@@ -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
 
@@ -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
  }
package/lib/core/wire.mjs CHANGED
@@ -20,7 +20,10 @@ export { webfingerHost } from '../pod/urls.mjs';
20
20
  // contains; the browser build states `fedipod/` on its own configs. A config
21
21
  // that names a root is always believed — this is only the answer for one that
22
22
  // does not.
23
- export const DEFAULT_ROOT = 'activitypods-js/';
23
+ // Every build writes its data into a `fedipod/` container in the pod. This is
24
+ // the answer for a config that names no root; a config that names one is always
25
+ // believed. (`activitypods-js/` was an earlier name, now abandoned.)
26
+ export const DEFAULT_ROOT = 'fedipod/';
24
27
 
25
28
  // The handle the fediverse sees: a fronted identity's name is the front's.
26
29
  export function publicHandle(config) {
@@ -602,29 +605,33 @@ export function contentHtml(text, mentions = []) {
602
605
  return '<p>' + html.replace(/\n+/g, '</p><p>') + '</p>';
603
606
  }
604
607
 
605
- export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
606
- visibility = 'public', summary = null, updated = null, container = null }) {
607
- const id = (container || urls.notes) + slug;
608
- const who = mentions.map(m => m.actor);
609
- // Mastodon's four shapes. Public and unlisted are world-readable; private
610
- // (followers-only) and direct carry no Public address at all and belong in
611
- // the owner-only container. Mastodon addresses mentions in cc (to, for a
612
- // direct post) and will not notify anyone it does not find there or in the
613
- // tags.
614
- const addressing = {
608
+ // Mastodon's four shapes. Public and unlisted are world-readable; private
609
+ // (followers-only) and direct carry no Public address at all and belong in
610
+ // the owner-only container. Mastodon addresses mentions in cc (to, for a
611
+ // direct post) and will not notify anyone it does not find there or in the
612
+ // tags.
613
+ export function addressing(urls, visibility, who = []) {
614
+ return {
615
615
  public: { to: [PUBLIC], cc: [urls.followers, ...who] },
616
616
  unlisted: { to: [urls.followers], cc: [PUBLIC, ...who] },
617
617
  private: { to: [urls.followers], cc: who },
618
618
  direct: { to: who, cc: [] },
619
619
  }[visibility] || { to: [PUBLIC], cc: [urls.followers, ...who] };
620
+ }
621
+
622
+ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
623
+ visibility = 'public', summary = null, updated = null, container = null }) {
624
+ const id = (container || urls.notes) + slug;
625
+ const who = mentions.map(m => m.actor);
626
+ const addressed = addressing(urls, visibility, who);
620
627
  const note = {
621
628
  '@context': AS_CTX,
622
629
  id, type: 'Note',
623
630
  attributedTo: urls.actor,
624
631
  content: contentHtml(content, mentions),
625
632
  published,
626
- to: addressing.to,
627
- cc: addressing.cc,
633
+ to: addressed.to,
634
+ cc: addressed.cc,
628
635
  replies: repliesId(id),
629
636
  };
630
637
  if (summary) note.summary = summary; // the content warning
@@ -47,7 +47,7 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
47
47
  };
48
48
  const podActorId = () => {
49
49
  const base = cfg.remotePod.endsWith('/') ? cfg.remotePod : `${cfg.remotePod}/`;
50
- const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'activitypods-js/';
50
+ const root = cfg.root ? (cfg.root.endsWith('/') ? cfg.root : `${cfg.root}/`) : 'fedipod/';
51
51
  return `${base}${root}ap/actor`;
52
52
  };
53
53
  // The reply first, the restart a beat later — same shape as /update.