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.
- package/README.md +9 -3
- package/device-agent.md +99 -0
- package/gateway.md +16 -0
- package/groups.md +1 -1
- package/gui.md +1 -1
- package/lib/client/c2s.mjs +95 -61
- package/lib/client/masto/index.mjs +7 -1
- package/lib/client/masto/timelines.mjs +1 -1
- package/lib/client/oidc-auth.mjs +5 -3
- 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/intake/index.mjs +32 -5
- package/lib/core/publisher/index.mjs +15 -1
- package/lib/core/publisher/notes.mjs +84 -2
- package/lib/core/publisher/questions.mjs +1 -0
- package/lib/core/publisher/restore.mjs +27 -2
- package/lib/core/social.mjs +1 -0
- package/lib/core/store.mjs +4 -0
- package/lib/core/wire.mjs +20 -13
- package/lib/device/admin/routes/gateway.mjs +1 -1
- package/lib/device/admin/surface.mjs +25 -17
- package/lib/device/cli/commands/setup.mjs +9 -1
- package/lib/device/setup.mjs +6 -0
- package/lib/gateway/front-core.mjs +66 -12
- package/lib/gateway/gateway-core.mjs +40 -0
- package/lib/pod/actor.mjs +2 -2
- package/lib/pod/root.mjs +11 -0
- package/lib/pod/transport.mjs +9 -3
- package/lib/server/embed.mjs +23 -6
- package/package.json +2 -2
- package/run-agent.mjs +10 -1
- package/scripts/refresh-contexts.mjs +5 -0
- package/vendor/gate.cjs +5 -2
- 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 +11 -1
- package/web/app/boot.mjs +49 -10
- package/web/app/dist/boot.js +89 -37
- package/web/app/dist/boot.js.map +3 -3
- package/web/app/dist/sw.js +1326 -662
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -6
- package/web/app/signup.mjs +3 -1
- 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 +1326 -662
- package/web/front/#new-account.html# +0 -43
- 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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 =
|
|
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
|
-
|
|
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
|
}
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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:
|
|
627
|
-
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}/`) : '
|
|
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.
|