fedipod 1.36.6 → 1.40.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 +29 -8
- package/architecture.md +8 -0
- package/bin/fedipod.mjs +6 -0
- package/cli.md +7 -1
- package/device-agent.md +3 -3
- package/gateway.md +69 -8
- package/groups.md +2 -1
- package/gui.md +3 -1
- package/lib/client/c2s.mjs +97 -43
- package/lib/client/masto/accounts.mjs +1 -0
- package/lib/client/masto/bridge.mjs +61 -0
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/client/masto/statuses.mjs +3 -0
- package/lib/connections/acctfeed.mjs +13 -9
- package/lib/connections/bskyfeed.mjs +13 -9
- package/lib/connections/tagfeed.mjs +14 -9
- package/lib/core/deliver.mjs +17 -1
- package/lib/core/intake/activities.mjs +18 -2
- package/lib/core/intake/group.mjs +3 -1
- package/lib/core/intake/index.mjs +226 -48
- package/lib/core/intake/notes.mjs +12 -5
- package/lib/core/intake/verify.mjs +11 -0
- package/lib/core/lease.mjs +15 -1
- package/lib/core/place.mjs +82 -0
- package/lib/core/pod-only.mjs +4 -0
- package/lib/core/publisher/collections.mjs +37 -2
- package/lib/core/publisher/index.mjs +25 -1
- package/lib/core/publisher/notes.mjs +73 -7
- package/lib/core/publisher/own.mjs +143 -0
- package/lib/core/publisher/questions.mjs +5 -3
- package/lib/core/scheduled.mjs +39 -0
- package/lib/core/social.mjs +62 -32
- package/lib/core/storage.mjs +67 -0
- package/lib/core/store.mjs +77 -5
- package/lib/core/wire.mjs +51 -15
- package/lib/device/admin/routes/lifecycle.mjs +1 -1
- package/lib/device/admin/routes/setup.mjs +17 -1
- package/lib/device/cli/commands/setup.mjs +50 -8
- package/lib/device/cli/context.mjs +1 -1
- package/lib/device/migrate.mjs +1 -1
- package/lib/device/setup.mjs +45 -8
- package/lib/gateway/account-agent.mjs +111 -0
- package/lib/gateway/copy.mjs +381 -0
- package/lib/gateway/front-core.mjs +74 -63
- package/lib/gateway/gateway-core.mjs +107 -9
- package/lib/gateway/held-mail.mjs +198 -0
- package/lib/gateway/keeper-due.mjs +39 -0
- package/lib/gateway/keeper-session.mjs +10 -0
- package/lib/gateway/keeper.mjs +72 -0
- package/lib/gateway/masto-gateway.mjs +510 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +89 -0
- package/lib/gateway/state-api.mjs +207 -0
- package/lib/gateway/token-claims.mjs +16 -0
- package/lib/pod/containers.mjs +17 -0
- package/lib/pod/location.mjs +52 -0
- package/lib/pod/notes.mjs +2 -4
- package/lib/pod/transport.mjs +214 -24
- package/lib/pod/type-index.mjs +101 -0
- package/lib/pod/urls.mjs +6 -0
- package/lib/server/embed.mjs +7 -6
- package/lib/session/README.md +5 -5
- package/lib/session/demo.html +1 -1
- package/lib/session/fedi-account.mjs +19 -10
- package/lib/session/package.json +2 -2
- package/package.json +2 -2
- package/run-agent.mjs +6 -16
- package/scripts/stage-site.mjs +17 -4
- package/web/admin/actors.js +2 -0
- package/web/admin/gateway.js +14 -1
- package/web/admin/index.html +13 -0
- package/web/admin/oauth-signin.mjs +1 -1
- package/web/admin/record.js +4 -1
- package/web/admin/setup/index.html +17 -1
- package/web/admin/setup/setup.js +18 -5
- package/web/app/README.md +2 -2
- package/web/app/admin-facade.mjs +12 -2
- package/web/app/agent.mjs +228 -51
- package/web/app/boot.mjs +75 -32
- package/web/app/copy-mode.mjs +223 -0
- package/web/app/dist/boot.js +498 -88
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +4016 -2539
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/index.html +16 -0
- package/web/app/signup.mjs +65 -26
- package/web/app/sw-src.mjs +15 -53
- package/web/app/update.js +3 -2
- package/web/app/warm-start.mjs +115 -0
- package/web/app-signin/app-signin.mjs +78 -0
- package/web/app-signin/index.html +41 -0
- package/web/front/run.html +7 -1
- package/web/front/run.js +30 -4
|
@@ -110,6 +110,17 @@ export async function readReceipt(intake, itemUrl) {
|
|
|
110
110
|
} catch { return null; }
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// A receipt carried inside a batch rather than beside its item: the same
|
|
114
|
+
// check, without the read.
|
|
115
|
+
export async function checkReceipt(intake, receipt) {
|
|
116
|
+
const secret = intake.gatewaySecret();
|
|
117
|
+
if (!secret || !receipt || typeof receipt !== 'object') return null;
|
|
118
|
+
try {
|
|
119
|
+
const { verifyReceipt } = await import('../../gateway/httpsig.mjs');
|
|
120
|
+
return verifyReceipt(receipt, secret) ? receipt : null;
|
|
121
|
+
} catch { return null; }
|
|
122
|
+
}
|
|
123
|
+
|
|
113
124
|
// Whether a receipt says anything about THIS actor. Verified-and-about-someone
|
|
114
125
|
// -else is worth exactly as much as unverified, and is treated the same way:
|
|
115
126
|
// the drain's verify-by-dereference still stands behind it.
|
package/lib/core/lease.mjs
CHANGED
|
@@ -149,6 +149,17 @@ export class Lease {
|
|
|
149
149
|
return true;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// A restarted browser worker that held the lease a moment ago: one read.
|
|
153
|
+
// Still ours answers { renewDue }, and when no renewal is due yet the lease
|
|
154
|
+
// is taken up as it stands, with no write. Not ours, or unreadable, is null.
|
|
155
|
+
async resume() {
|
|
156
|
+
const cur = await this.readFresh();
|
|
157
|
+
if (cur === UNREADABLE || !cur || cur.holder !== this.id || Date.now() >= cur.expiresAt) return null;
|
|
158
|
+
const renewDue = cur.expiresAt - Date.now() <= TTL_MS - RENEW_MS;
|
|
159
|
+
if (!renewDue) { this.heldUntil = cur.expiresAt; this.denied = null; }
|
|
160
|
+
return { renewDue };
|
|
161
|
+
}
|
|
162
|
+
|
|
152
163
|
// Self-scheduling rather than setInterval, so each agent's renewals drift
|
|
153
164
|
// apart instead of several beating in lockstep against one pod.
|
|
154
165
|
// Idempotent, like Deliverer.startQueue: startActive reaches this twice on the
|
|
@@ -199,8 +210,11 @@ export class Lease {
|
|
|
199
210
|
// point of the TTL is that after it another agent may start draining.
|
|
200
211
|
stillHeld() { return !this.stopped && (!this.heldUntil || Date.now() < this.heldUntil); }
|
|
201
212
|
|
|
213
|
+
// Only while it is still ours: a release landing after another agent took
|
|
214
|
+
// the lease over would otherwise stand that agent down. Without an ETag to
|
|
215
|
+
// ask with, as before.
|
|
202
216
|
async release() {
|
|
203
217
|
this.stopRenewal();
|
|
204
|
-
await this.write({ holder: this.id, expiresAt: 0 }).catch(() => {});
|
|
218
|
+
await this.write({ holder: this.id, expiresAt: 0 }, this.etag ? { ifMatch: this.etag } : {}).catch(() => {});
|
|
205
219
|
}
|
|
206
220
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// place.mjs — where on a pod a FediPod account lives, and recording it.
|
|
2
|
+
//
|
|
3
|
+
// The account's container is always named `fedipod`, inside a container its
|
|
4
|
+
// owner chose. The choice is recorded in their public type index as an
|
|
5
|
+
// instance of as:Actor (lib/pod/type-index.mjs), and that record is how the
|
|
6
|
+
// account is found again. An account made before the choice existed sits at
|
|
7
|
+
// `<pod>fedipod/` with no record, and is found there.
|
|
8
|
+
|
|
9
|
+
import * as typeIndex from '../pod/type-index.mjs';
|
|
10
|
+
import { PodTransport } from '../pod/transport.mjs';
|
|
11
|
+
import { rootFromContainer, rootOfActor } from '../pod/location.mjs';
|
|
12
|
+
import { DEFAULT_ROOT } from './wire.mjs';
|
|
13
|
+
|
|
14
|
+
export { podRootPath } from '../pod/location.mjs';
|
|
15
|
+
|
|
16
|
+
/** The container someone typed, as the account's root — or why it cannot be. */
|
|
17
|
+
export const chosenRoot = (podBase, typed) => rootFromContainer(podBase, typed, DEFAULT_ROOT);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The roots of the accounts this pod's owner has recorded, then the place an
|
|
21
|
+
* older account sits without a record. `pod` is a transport acting as the owner.
|
|
22
|
+
*/
|
|
23
|
+
export async function candidateRoots(pod, podBase) {
|
|
24
|
+
const recorded = await typeIndex.registeredActors(pod, podBase).catch(() => []);
|
|
25
|
+
const roots = recorded.map(a => rootOfActor(podBase, a)).filter(Boolean);
|
|
26
|
+
return [...new Set([...roots, DEFAULT_ROOT])];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The account on this pod: the first candidate whose config `read(root)` finds
|
|
31
|
+
* and `accept(config)` takes. Null when there is none.
|
|
32
|
+
*/
|
|
33
|
+
export async function findAccount(pod, podBase, read, accept = () => true) {
|
|
34
|
+
for (const root of await candidateRoots(pod, podBase)) {
|
|
35
|
+
const config = await read(root).catch(() => null);
|
|
36
|
+
if (config && accept(config)) return { root, config };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Record the account's place in the owner's public type index. With no index,
|
|
43
|
+
* one is made only when `create` is true — the person's own yes. Returns what
|
|
44
|
+
* was done: 'registered', 'already', or 'no-index'.
|
|
45
|
+
*/
|
|
46
|
+
export async function recordPlace(pod, podBase, actorAtPod, { create = false } = {}) {
|
|
47
|
+
let index = await typeIndex.findPublicIndex(pod, podBase);
|
|
48
|
+
if (!index) {
|
|
49
|
+
if (!create) return 'no-index';
|
|
50
|
+
index = await typeIndex.createPublicIndex(pod, podBase);
|
|
51
|
+
}
|
|
52
|
+
return (await typeIndex.register(pod, index, actorAtPod)) ? 'registered' : 'already';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether this person's profile names a public type index. */
|
|
56
|
+
export async function hasPublicIndex(pod, podBase) {
|
|
57
|
+
return !!(await typeIndex.findPublicIndex(pod, podBase).catch(() => null));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a pod's owner has a public type index, asked as a stranger before any
|
|
62
|
+
* credential exists: true, false, or null when the profile could not be read.
|
|
63
|
+
*/
|
|
64
|
+
export async function publicIndexKnown(fetchImpl, podBase, webId = `${podBase}profile/card#me`) {
|
|
65
|
+
const pod = new PodTransport({ fetch: fetchImpl }, { webId });
|
|
66
|
+
try { return !!(await typeIndex.findPublicIndex(pod, podBase)); } catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The root for a container typed before the pod's own path was known — a new
|
|
71
|
+
* pod: the answer is taken as inside the pod's root, whatever it turns out to be.
|
|
72
|
+
*/
|
|
73
|
+
export function chosenRootInNewPod(podBase, typed) {
|
|
74
|
+
const inner = String(typed ?? '').trim().replace(/^\/+/, '');
|
|
75
|
+
return chosenRoot(podBase, new URL(podBase).pathname + inner);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The error that stops a setup until its person has said yes to a new type index. */
|
|
79
|
+
export function needsIndex(detail) {
|
|
80
|
+
return Object.assign(new Error(`${detail} FediPod records where your account lives in your public type index, `
|
|
81
|
+
+ 'so it needs one. Say yes to creating it, or nothing is set up.'), { code: 'needs-index' });
|
|
82
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// pod-only.mjs — what stays on the pod, and only there, when an account works
|
|
2
|
+
// from a copy kept at its gateway (lib/gateway/copy.mjs): the signing key, the
|
|
3
|
+
// pod's own lease, and the passwords and tokens of accounts on other servers.
|
|
4
|
+
export const podOnly = (name) => name === 'keys.json' || name === 'lease.json' || name.startsWith('conn-');
|
|
@@ -9,6 +9,7 @@ import * as podFollowers from '../../pod/followers.mjs';
|
|
|
9
9
|
import * as podFollowing from '../../pod/following.mjs';
|
|
10
10
|
import * as podFeatured from '../../pod/featured.mjs';
|
|
11
11
|
import * as podPrivate from '../../pod/private.mjs';
|
|
12
|
+
import * as podNotes from '../../pod/notes.mjs';
|
|
12
13
|
|
|
13
14
|
// The default for publishCollections: the whole public surface, ACLs included.
|
|
14
15
|
// A caller that knows what it changed narrows it; a caller that says nothing
|
|
@@ -133,6 +134,9 @@ export function readPublishedFollowers(publisher) { return podFollowers.readPubl
|
|
|
133
134
|
|
|
134
135
|
export async function publishCollections(publisher, which = ALL_COLLECTIONS) {
|
|
135
136
|
const { urls } = publisher;
|
|
137
|
+
// A block takes the blocked out of the followers too (PodStore.setBlocklist),
|
|
138
|
+
// so the published list changes with it.
|
|
139
|
+
if (which.blocked) which = { ...which, followers: true };
|
|
136
140
|
const contacts = publisher.store.getContacts();
|
|
137
141
|
if (which.followers) {
|
|
138
142
|
// Reconcile walks the published pages, so — like the outbox — pay for it
|
|
@@ -147,7 +151,8 @@ export async function publishCollections(publisher, which = ALL_COLLECTIONS) {
|
|
|
147
151
|
}
|
|
148
152
|
if (which.following) {
|
|
149
153
|
await podFollowing.write(publisher.remote, urls,
|
|
150
|
-
|
|
154
|
+
// newest first (§5): contacts append as follows are made
|
|
155
|
+
wire.orderedCollection(urls.following, contacts.following.filter(f => f.accepted).map(f => f.actor).reverse()),
|
|
151
156
|
{ publicRead: which.acls });
|
|
152
157
|
}
|
|
153
158
|
if (which.pending) await publisher.publishPending();
|
|
@@ -158,6 +163,14 @@ export async function publishCollections(publisher, which = ALL_COLLECTIONS) {
|
|
|
158
163
|
// reader, a refusal nothing caches, and every server asked again.
|
|
159
164
|
if (which.featured) await publisher.publishFeatured();
|
|
160
165
|
if (which.outbox) {
|
|
166
|
+
// A group made private takes back what it carried while open, as it goes:
|
|
167
|
+
// those posts were carried for its members, and a group turned private must
|
|
168
|
+
// not go on listing them to the world. Taken back, not hidden, so opening
|
|
169
|
+
// the group again does not put them back.
|
|
170
|
+
if (publisher.config?.kind === 'group' && publisher.config?.private) {
|
|
171
|
+
const carry = (i) => i?.type === 'Announce' || i?.type === 'Undo';
|
|
172
|
+
if (publisher.store.read('outbox.json', []).some(carry)) await unrecordOutbox(publisher, carry);
|
|
173
|
+
}
|
|
161
174
|
const outbox = publisher.store.read('outbox.json', []);
|
|
162
175
|
// Reconcile reads every published page, which is the expensive part of a
|
|
163
176
|
// profile save. It is worth paying only when we are about to write pages
|
|
@@ -206,14 +219,32 @@ export async function recordOutbox(publisher, item) {
|
|
|
206
219
|
const outbox = publisher.store.read('outbox.json', []);
|
|
207
220
|
outbox.unshift(item);
|
|
208
221
|
publisher.store.write('outbox.json', outbox);
|
|
222
|
+
await writeEntry(publisher, item);
|
|
209
223
|
await publisher.publishOutbox(outbox);
|
|
210
224
|
}
|
|
211
225
|
|
|
226
|
+
// An entry kept whole — a boost, an edit, a deletion, an undo — is also written
|
|
227
|
+
// as its own document at its id, beside the posts, so it can be fetched (§3.1).
|
|
228
|
+
// Only for the public record, and only where the id is one of those documents.
|
|
229
|
+
async function writeEntry(publisher, item) {
|
|
230
|
+
const notes = publisher.urls?.notes;
|
|
231
|
+
if (!notes || !item || typeof item !== 'object' || typeof item.id !== 'string' || !item.id.startsWith(notes)) return;
|
|
232
|
+
await podNotes.writeCreate(publisher.remote, item.id, { '@context': wire.AS_CTX, ...item })
|
|
233
|
+
.catch(e => publisher.log?.(`${item.type} ${item.id} not written as its own document: ${e.message}`));
|
|
234
|
+
}
|
|
235
|
+
|
|
212
236
|
// Taking something out of the outbox is a DECISION — a post deleted, a boost
|
|
213
237
|
// undone. It leaves a mark for the same reason dropFollower does: the pod's
|
|
214
238
|
// copy is rewritten right after, but a rewrite that fails would otherwise let
|
|
215
239
|
// the next reconcile put the entry back. Bounded, like the follower one.
|
|
216
|
-
|
|
240
|
+
//
|
|
241
|
+
// What took it back goes in its place: a Delete for a deleted post, an Undo for
|
|
242
|
+
// a withdrawn boost. A reader catching up from the outbox otherwise never learns
|
|
243
|
+
// the entry it saw earlier was withdrawn. `record(gone)` names that activity (or
|
|
244
|
+
// several, newest last), or null when nothing it cares about was in the outbox
|
|
245
|
+
// (a private post never was). Both land in the one write, so the outbox is
|
|
246
|
+
// never seen with the entry gone and its reason missing.
|
|
247
|
+
export async function unrecordOutbox(publisher, matches, { record = null } = {}) {
|
|
217
248
|
const before = publisher.store.read('outbox.json', []);
|
|
218
249
|
const outbox = before.filter(i => !matches(i));
|
|
219
250
|
const gone = before.filter(i => matches(i))
|
|
@@ -224,8 +255,12 @@ export async function unrecordOutbox(publisher, matches) {
|
|
|
224
255
|
publisher.store.write('outbox-removed.json',
|
|
225
256
|
[...marks, ...gone.map(id => ({ id, at }))].slice(-500));
|
|
226
257
|
}
|
|
258
|
+
const reasons = [].concat((gone.length && record ? record(gone) : null) || []);
|
|
259
|
+
for (const r of reasons) { outbox.unshift(r); await writeEntry(publisher, r); }
|
|
227
260
|
publisher.store.write('outbox.json', outbox);
|
|
228
261
|
await publisher.publishOutbox(outbox);
|
|
262
|
+
publisher.unrecordOwn?.(matches);
|
|
263
|
+
return gone;
|
|
229
264
|
}
|
|
230
265
|
|
|
231
266
|
// The pinned posts, as the actor's featured collection — the one document a
|
|
@@ -28,6 +28,8 @@ import { ALL_COLLECTIONS } from './collections.mjs';
|
|
|
28
28
|
import * as restore from './restore.mjs';
|
|
29
29
|
import * as notes from './notes.mjs';
|
|
30
30
|
import * as questions from './questions.mjs';
|
|
31
|
+
import * as own from './own.mjs';
|
|
32
|
+
import { recordPlace } from '../place.mjs';
|
|
31
33
|
|
|
32
34
|
const AGENT_VERSION = JSON.parse(fs.readFileSync(
|
|
33
35
|
path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../package.json'), 'utf8')).version;
|
|
@@ -40,6 +42,8 @@ export class Publisher {
|
|
|
40
42
|
this.remote = remote;
|
|
41
43
|
this.store = store;
|
|
42
44
|
this.deliverer = deliverer;
|
|
45
|
+
// Everything this actor sends goes on the outbox its owner reads.
|
|
46
|
+
if (deliverer) deliverer.onSent = (a) => own.recordOwn(this, a);
|
|
43
47
|
this.publicKeyPem = publicKeyPem;
|
|
44
48
|
this.assertionKey = assertionKey; // Ed25519 public half, multibase; null = no proofs
|
|
45
49
|
// Where this identity's client surface answers, when that is an address a
|
|
@@ -111,6 +115,9 @@ export class Publisher {
|
|
|
111
115
|
postingRestrictedToMods: this.config.kind === 'group' ? !!this.config.postingRestrictedToMods : null,
|
|
112
116
|
moderators,
|
|
113
117
|
pendingFollowers: priv ? urls.pendingFollowers : null,
|
|
118
|
+
// Named only where the private folder is proved to keep it private.
|
|
119
|
+
liked: priv ? urls.liked : null,
|
|
120
|
+
ownerOutbox: priv ? urls.ownOutbox : null,
|
|
114
121
|
pendingFollowing: priv ? urls.pendingFollowing : null,
|
|
115
122
|
blocked: priv ? urls.blocked : null,
|
|
116
123
|
inbox,
|
|
@@ -191,6 +198,8 @@ export class Publisher {
|
|
|
191
198
|
actor: actorDoc, handle: this.config.handle, host,
|
|
192
199
|
quiesced: !!this.config.quiescedAt, version: AGENT_VERSION,
|
|
193
200
|
moderators: this.config.moderators || [],
|
|
201
|
+
// Who else the rules name: granting or withdrawing a keeper rewrites them.
|
|
202
|
+
keepers: this.remote?.keepers || [],
|
|
194
203
|
})).digest('hex').slice(0, 32);
|
|
195
204
|
// The human page has its own gate: it changes with pins and the joined
|
|
196
205
|
// date as well as with the profile, and costs one write when it does.
|
|
@@ -244,6 +253,13 @@ export class Publisher {
|
|
|
244
253
|
} catch (e) {
|
|
245
254
|
this.log(`WebID profile not updated with the actor link: ${e.message}`);
|
|
246
255
|
}
|
|
256
|
+
// Where the account lives, in the owner's public type index — only where
|
|
257
|
+
// they already have one; an index is made only on their yes, at sign-up.
|
|
258
|
+
if (!this.config.forum) {
|
|
259
|
+
await recordPlace(this.remote, urls.base, urls.home + 'ap/actor')
|
|
260
|
+
.then((r) => { if (r === 'registered') this.log('the public type index now records where the account lives'); })
|
|
261
|
+
.catch((e) => this.log(`type index not updated with the account's place: ${e.message}`));
|
|
262
|
+
}
|
|
247
263
|
|
|
248
264
|
// inbox: public may only Append; owner (the agent) reads + drains. A
|
|
249
265
|
// quiesced actor keeps its name resolving but takes no more mail, so a
|
|
@@ -277,7 +293,7 @@ export class Publisher {
|
|
|
277
293
|
this.log(this.config.gateway?.frontActor || wire.webfingerHost(urls.base)
|
|
278
294
|
? `profile published: @${pubName}@${pubHost} → ${urls.actor}`
|
|
279
295
|
: `profile published → ${urls.actor} — NOT discoverable as @${pubName}@${pubHost}: `
|
|
280
|
-
+ 'this pod is
|
|
296
|
+
+ 'this pod is suffixed, and WebFinger is only answered for a subdomained pod');
|
|
281
297
|
return { unreachable, updated };
|
|
282
298
|
}
|
|
283
299
|
|
|
@@ -468,6 +484,14 @@ export class Publisher {
|
|
|
468
484
|
publishBlocked(...a) { return collections.publishBlocked(this, ...a); }
|
|
469
485
|
recordOutbox(...a) { return collections.recordOutbox(this, ...a); }
|
|
470
486
|
unrecordOutbox(...a) { return collections.unrecordOutbox(this, ...a); }
|
|
487
|
+
recordOwn(...a) { return own.recordOwn(this, ...a); }
|
|
488
|
+
backfillLiked() { return own.backfillLiked(this); }
|
|
489
|
+
inboxesFor(...a) { return notes.inboxesFor(this, ...a); }
|
|
490
|
+
noteToSelf(...a) { return notes.noteToSelf(this, ...a); }
|
|
491
|
+
updateObject(...a) { return notes.updateObject(this, ...a); }
|
|
492
|
+
unrecordOwn(...a) { return own.unrecordOwn(this, ...a); }
|
|
493
|
+
publishOwn(...a) { return own.publishOwn(this, ...a); }
|
|
494
|
+
ownSettled() { return own.ownSettled(this); }
|
|
471
495
|
publishFeatured(...a) { return collections.publishFeatured(this, ...a); }
|
|
472
496
|
|
|
473
497
|
// restore.mjs
|
|
@@ -79,7 +79,9 @@ export async function mentionsFor(publisher, content, inReplyTo) {
|
|
|
79
79
|
const doc = await publisher.resolveMention(handle).catch(() => null);
|
|
80
80
|
if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
|
|
81
81
|
if (!inText.has(handle) && doc.type !== 'Group') continue; // author trimmed them out
|
|
82
|
-
|
|
82
|
+
// Naming yourself still tags you; it never delivers to your own inbox (§7.1).
|
|
83
|
+
const self = doc.id === publisher.urls.actor;
|
|
84
|
+
mentions.push({ handle, actor: doc.id, page: doc.url || null, inbox: self ? null : doc.endpoints?.sharedInbox || doc.inbox });
|
|
83
85
|
}
|
|
84
86
|
return mentions;
|
|
85
87
|
}
|
|
@@ -144,12 +146,62 @@ export function assertDirectAddressed(content, mentions) {
|
|
|
144
146
|
// otherwise the agent mints one as it always has.
|
|
145
147
|
const SLUG_OK = /^[A-Za-z0-9._-]{1,64}$/u;
|
|
146
148
|
export const safeSlug = (s) => (typeof s === 'string' && SLUG_OK.test(s) && !/^\.+$/u.test(s) ? s : null);
|
|
147
|
-
async function slugFor(publisher, container, wanted, published) {
|
|
149
|
+
export async function slugFor(publisher, container, wanted, published) {
|
|
148
150
|
const name = safeSlug(wanted);
|
|
149
151
|
if (name && !(await podNotes.read(publisher.remote, container + name).catch(() => null))) return name;
|
|
150
152
|
return published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
// A message from this account to itself, for something its owner must hear
|
|
156
|
+
// and has no other way to: a post an app sent through the outbox door that the
|
|
157
|
+
// account then refused, after the door had already told the app "created".
|
|
158
|
+
// Addressed to the owner alone and kept in the private container, so it shows
|
|
159
|
+
// in the owner's apps as a direct message and a mention, and nowhere else.
|
|
160
|
+
export async function noteToSelf(publisher, text) {
|
|
161
|
+
const { urls } = publisher;
|
|
162
|
+
const published = new Date().toISOString();
|
|
163
|
+
const slug = published.slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex');
|
|
164
|
+
const id = urls.privateNotes + slug;
|
|
165
|
+
const content = wire.contentHtml(text);
|
|
166
|
+
await podNotes.write(publisher.remote, id, { '@context': wire.AS_CTX, id, type: 'Note',
|
|
167
|
+
attributedTo: urls.actor, to: [urls.actor], published, content })
|
|
168
|
+
.catch(e => publisher.log(`note to self kept here only — the pod would not take it: ${e.message}`));
|
|
169
|
+
publisher.store.addStatus({ noteId: id, actor: urls.actor, content, text, published,
|
|
170
|
+
kind: 'post', visibility: 'direct', slug });
|
|
171
|
+
publisher.store.addNotification({ type: 'mention', actor: urls.actor, noteId: id });
|
|
172
|
+
return id;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// An app's edit of an object that is not a Note — an annotation, a poll —
|
|
176
|
+
// changes only the fields it sends (§6.3); a field sent as null is removed.
|
|
177
|
+
// What the object is, who made it and when, where it lives and who it is
|
|
178
|
+
// addressed to stay as they are, and so do a poll's choices and its count.
|
|
179
|
+
const FIXED = new Set(['@context', 'id', 'type', 'attributedTo', 'published', 'to', 'cc', 'bto', 'bcc',
|
|
180
|
+
'audience', 'oneOf', 'anyOf', 'endTime', 'closed', 'votersCount', 'replies']);
|
|
181
|
+
export async function updateObject(publisher, s, patch, { updated = new Date().toISOString() } = {}) {
|
|
182
|
+
const { urls } = publisher;
|
|
183
|
+
const doc = await podNotes.read(publisher.remote, s.noteId);
|
|
184
|
+
if (!doc?.id) throw new Error('the object is not on the pod to change');
|
|
185
|
+
const next = { ...doc };
|
|
186
|
+
for (const [k, v] of Object.entries(patch || {})) {
|
|
187
|
+
if (FIXED.has(k)) continue;
|
|
188
|
+
if (v === null) delete next[k]; else next[k] = v;
|
|
189
|
+
}
|
|
190
|
+
if (typeof next.content === 'string') next.content = wire.sanitizeHtml(next.content);
|
|
191
|
+
next.updated = updated;
|
|
192
|
+
await podNotes.write(publisher.remote, doc.id, next);
|
|
193
|
+
await podNotes.writeCreate(publisher.remote, wire.createActivityId(doc.id), wire.createActivity(next, urls));
|
|
194
|
+
publisher.store.updateStatus(s.noteId, { content: rowContent(next), editedAt: updated });
|
|
195
|
+
const update = wire.updateActivity(next, urls);
|
|
196
|
+
const inboxes = [...new Set([
|
|
197
|
+
...(s.visibility === 'direct' ? [] : publisher.store.getContacts().followers.map(f => f.sharedInbox || f.inbox)),
|
|
198
|
+
...(await inboxesFor(publisher, [...(s.addressed || []), ...(s.replyActor ? [s.replyActor] : [])])),
|
|
199
|
+
].filter(Boolean))];
|
|
200
|
+
await publisher.deliverer.deliverToAll(inboxes, update);
|
|
201
|
+
if (publisher.store.read('outbox.json', []).includes(s.noteId)) await publisher.recordOutbox(update);
|
|
202
|
+
return update;
|
|
203
|
+
}
|
|
204
|
+
|
|
153
205
|
// What the author's own timeline shows for an object: its content, else its
|
|
154
206
|
// text under one of the names other vocabularies use, else a link to it.
|
|
155
207
|
export function rowContent(obj) {
|
|
@@ -173,7 +225,9 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
173
225
|
const published = new Date().toISOString();
|
|
174
226
|
const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
|
|
175
227
|
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
176
|
-
|
|
228
|
+
// Addressed by id (a client-to-server post) it is addressed already; only a
|
|
229
|
+
// post that names nobody but in its text has to name them there.
|
|
230
|
+
if (visibility === 'direct' && !also.length && !deliverTo.length) assertDirectAddressed(content, mentions);
|
|
177
231
|
const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
|
|
178
232
|
const selfQuote = quote?.id && quote.actor === urls.actor;
|
|
179
233
|
const quoted = quote?.id ? {
|
|
@@ -199,6 +253,11 @@ export async function publishNote(publisher, content, { inReplyTo, attachments,
|
|
|
199
253
|
publisher.store.addStatus({
|
|
200
254
|
noteId: note.id, actor: urls.actor, content: note.content, published, inReplyTo,
|
|
201
255
|
kind: 'post', slug, text: content, visibility,
|
|
256
|
+
// Everyone a client named, blind copies included: an edit or a deletion
|
|
257
|
+
// must reach them too. Kept here only, never published. `named` is the
|
|
258
|
+
// visible part, which an edit addresses again.
|
|
259
|
+
...(also.length || deliverTo.length ? { addressed: [...new Set([...also, ...deliverTo])] } : {}),
|
|
260
|
+
...(also.length ? { named: [...also] } : {}),
|
|
202
261
|
...(spoilerText ? { spoiler: spoilerText } : {}),
|
|
203
262
|
...(note.sensitive ? { sensitive: true } : {}),
|
|
204
263
|
...(attachments?.length ? { attachments } : {}),
|
|
@@ -301,6 +360,8 @@ export async function publishObject(publisher, object, { visibility = 'public',
|
|
|
301
360
|
publisher.store.addStatus({
|
|
302
361
|
noteId: id, actor: urls.actor, content: rowContent(row), published: row.published || published,
|
|
303
362
|
kind: 'post', slug: name, visibility,
|
|
363
|
+
...(also.length || deliverTo.length ? { addressed: [...new Set([...also, ...deliverTo])] } : {}),
|
|
364
|
+
...(also.length ? { named: [...also] } : {}),
|
|
304
365
|
});
|
|
305
366
|
if (await publisher.store.commit?.() === false) publisher.log(`object published but its timeline row was refused: ${id}`);
|
|
306
367
|
|
|
@@ -332,16 +393,16 @@ export function rowTags(note) {
|
|
|
332
393
|
return { ...(mentions.length ? { mentions } : {}), ...(hashtags.length ? { tags: hashtags } : {}) };
|
|
333
394
|
}
|
|
334
395
|
|
|
335
|
-
export async function updateNote(publisher, s, { content, spoilerText = null, sensitive = null, attachments = null } = {}) {
|
|
396
|
+
export async function updateNote(publisher, s, { content, spoilerText = null, sensitive = null, attachments = null, updated = new Date().toISOString() } = {}) {
|
|
336
397
|
const { urls } = publisher;
|
|
337
|
-
const updated = new Date().toISOString();
|
|
338
398
|
const inText = new Set(wire.mentionsIn(content));
|
|
339
399
|
const mentions = [];
|
|
340
400
|
for (const handle of inText) {
|
|
341
401
|
if (!publisher.resolveMention) break;
|
|
342
402
|
const doc = await publisher.resolveMention(handle).catch(() => null);
|
|
343
403
|
if (!doc?.id) { publisher.log(`mention @${handle} did not resolve — left as text`); continue; }
|
|
344
|
-
mentions.push({ handle, actor: doc.id, page: doc.url || null,
|
|
404
|
+
mentions.push({ handle, actor: doc.id, page: doc.url || null,
|
|
405
|
+
inbox: doc.id === urls.actor ? null : doc.endpoints?.sharedInbox || doc.inbox });
|
|
345
406
|
}
|
|
346
407
|
const atts = attachments ?? s.attachments ?? [];
|
|
347
408
|
// The note stays in the container its visibility put it in; a recovered
|
|
@@ -353,7 +414,7 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
|
|
|
353
414
|
urls, slug, content, published: s.published, inReplyTo: s.inReplyTo,
|
|
354
415
|
attachments: atts, mentions, visibility: s.visibility || 'public',
|
|
355
416
|
summary: spoilerText, sensitive: sensitive ?? !!s.sensitive, updated, container,
|
|
356
|
-
also: reply ? [reply.actor] : [],
|
|
417
|
+
also: [...new Set([...(s.named || []), ...(reply ? [reply.actor] : [])])],
|
|
357
418
|
quote: rowQuote(s),
|
|
358
419
|
});
|
|
359
420
|
await podNotes.write(publisher.remote, note.id, note);
|
|
@@ -372,8 +433,13 @@ export async function updateNote(publisher, s, { content, spoilerText = null, se
|
|
|
372
433
|
...(s.visibility === 'direct' ? [] : contacts.followers.map(f => f.sharedInbox || f.inbox)),
|
|
373
434
|
...mentions.map(m => m.inbox),
|
|
374
435
|
reply?.inbox,
|
|
436
|
+
// everyone a client named or blind-copied, and a quoted author
|
|
437
|
+
...(await inboxesFor(publisher, [...(s.addressed || []), ...(s.quoteRequest?.actor ? [s.quoteRequest.actor] : [])])),
|
|
375
438
|
].filter(Boolean))];
|
|
376
439
|
await publisher.deliverer.deliverToAll(inboxes, update);
|
|
440
|
+
// The edit goes on the record beside the Create, for a post the outbox lists:
|
|
441
|
+
// a reader catching up from the outbox otherwise keeps the words it first saw.
|
|
442
|
+
if (publisher.store.read('outbox.json', []).includes(s.noteId)) await publisher.recordOutbox(update);
|
|
377
443
|
publisher.log(`note edited: ${note.id} → ${inboxes.length} inbox(es)`);
|
|
378
444
|
return patched;
|
|
379
445
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// own.mjs — the outbox as its owner reads it, and the owner's liked list.
|
|
2
|
+
//
|
|
3
|
+
// The public outbox is what a stranger may see: posts and boosts, and what
|
|
4
|
+
// withdrew them. ActivityPub's outbox is every message the actor produced,
|
|
5
|
+
// filtered by who is asking (§5.1), so its owner is owed the rest as well —
|
|
6
|
+
// likes, follows, undos, follower decisions, followers-only and direct posts.
|
|
7
|
+
// That view lives in the private container, whose owner-only rule it inherits,
|
|
8
|
+
// and is what the outbox address answers its signed-in owner.
|
|
9
|
+
//
|
|
10
|
+
// Everything this actor sends passes through the deliverer, which hands each
|
|
11
|
+
// fresh activity here (Deliverer.onSent), so nothing has to remember to record
|
|
12
|
+
// itself. What is never sent — a block or a pin posted by a client — is
|
|
13
|
+
// recorded by the client-to-server dispatcher.
|
|
14
|
+
//
|
|
15
|
+
// `liked` (§5.5) is the objects this actor has liked and not taken back,
|
|
16
|
+
// kept beside it under the same rule.
|
|
17
|
+
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import * as wire from '../wire.mjs';
|
|
20
|
+
import * as collection from '../../pod/collection.mjs';
|
|
21
|
+
|
|
22
|
+
const OWN = 'outbox-own.json';
|
|
23
|
+
const LIKED = 'liked.json';
|
|
24
|
+
// The owner's view keeps its newest entries; older ones leave the view only —
|
|
25
|
+
// the public outbox, and everything the account did, are untouched.
|
|
26
|
+
export const OWN_MAX = 5000;
|
|
27
|
+
|
|
28
|
+
// A Create is kept by its object's id, as the public outbox keeps it, so the
|
|
29
|
+
// two can be matched when a post is withdrawn; anything else is kept whole.
|
|
30
|
+
const localForm = (a) => (a?.type === 'Create' && (typeof a.object === 'string' ? a.object : a.object?.id)) || a;
|
|
31
|
+
|
|
32
|
+
export function recordOwn(publisher, activity) {
|
|
33
|
+
const { urls, store } = publisher;
|
|
34
|
+
if (!activity?.type || activity.actor !== urls.actor) return false;
|
|
35
|
+
const item = localForm(activity);
|
|
36
|
+
const id = wire.outboxItemId(item);
|
|
37
|
+
const own = store.read(OWN, []);
|
|
38
|
+
if (id && own.some(i => wire.outboxItemId(i) === id)) return false; // a retry, or said twice
|
|
39
|
+
own.unshift(item);
|
|
40
|
+
store.write(OWN, own.slice(0, OWN_MAX));
|
|
41
|
+
if (activity.type === 'Like' || (activity.type === 'Undo' && activity.object?.type === 'Like')) {
|
|
42
|
+
backfillLiked(publisher);
|
|
43
|
+
const object = wire.outboxItemId(activity.type === 'Like' ? activity.object : activity.object.object);
|
|
44
|
+
if (object) {
|
|
45
|
+
const liked = store.read(LIKED, []).filter(o => o !== object);
|
|
46
|
+
if (activity.type === 'Like') liked.unshift(object);
|
|
47
|
+
store.write(LIKED, liked);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
schedule(publisher);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// An account that liked things before the list existed: the likes it still
|
|
55
|
+
// holds (a timeline row marked favourited, with the Like that was sent) are
|
|
56
|
+
// its liked list to begin with. Once only — a list that exists, even empty,
|
|
57
|
+
// is the record from then on.
|
|
58
|
+
export function backfillLiked(publisher) {
|
|
59
|
+
const { store } = publisher;
|
|
60
|
+
if (store.read(LIKED, null) !== null) return 0;
|
|
61
|
+
const liked = (store.getStatuses?.() || [])
|
|
62
|
+
.filter(s => s.favourited && s.noteId)
|
|
63
|
+
.sort((x, y) => String(y.published || '').localeCompare(String(x.published || '')))
|
|
64
|
+
.map(s => s.noteId);
|
|
65
|
+
store.write(LIKED, [...new Set(liked)]);
|
|
66
|
+
schedule(publisher);
|
|
67
|
+
return liked.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A post deleted or a boost withdrawn leaves the owner's view as it leaves the
|
|
71
|
+
// public one; the Delete or Undo that did it is already recorded, having been
|
|
72
|
+
// sent.
|
|
73
|
+
export function unrecordOwn(publisher, matches) {
|
|
74
|
+
const own = publisher.store.read(OWN, []);
|
|
75
|
+
const kept = own.filter(i => !matches(i));
|
|
76
|
+
if (kept.length === own.length) return;
|
|
77
|
+
publisher.store.write(OWN, kept);
|
|
78
|
+
schedule(publisher);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Recording never waits on the pod: a like is answered when it is sent, and
|
|
82
|
+
// the pages follow. One publish at a time, the last one covering every record
|
|
83
|
+
// made while it waited.
|
|
84
|
+
function schedule(publisher) {
|
|
85
|
+
if (publisher._ownQueued) return;
|
|
86
|
+
publisher._ownQueued = true;
|
|
87
|
+
publisher._ownChain = (publisher._ownChain || Promise.resolve()).then(async () => {
|
|
88
|
+
publisher._ownQueued = false;
|
|
89
|
+
try { await publishOwn(publisher); }
|
|
90
|
+
catch (e) { publisher.log?.(`owner's outbox view not published: ${e.message}`); }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ownSettled(publisher) { return publisher._ownChain || Promise.resolve(); }
|
|
95
|
+
|
|
96
|
+
export async function publishOwn(publisher) {
|
|
97
|
+
const { urls, store, remote } = publisher;
|
|
98
|
+
// Written only where the private folder is proved to keep it private — the
|
|
99
|
+
// same bar the pending and blocked lists clear. Recorded meanwhile, and
|
|
100
|
+
// published the first time the bar is met.
|
|
101
|
+
if (publisher.privateReady && await publisher.privateReady() !== true) return;
|
|
102
|
+
const seen = store.read('published.json', {});
|
|
103
|
+
const own = store.read(OWN, []);
|
|
104
|
+
const out = wire.outboxPaging(own, seen.ownIndex || []);
|
|
105
|
+
const ownPages = await writePages(remote, seen.ownPages, out.pages.map((items, i) => ({
|
|
106
|
+
id: wire.outboxPageId(urls.ownOutbox, i + 1), doc: wire.outboxPage(urls.ownOutbox, i + 1, items),
|
|
107
|
+
})));
|
|
108
|
+
const ownHead = await writeHead(remote, seen.ownHead, urls.ownOutbox, wire.outboxHead(urls.ownOutbox, own.length, out.pages.length));
|
|
109
|
+
|
|
110
|
+
const liked = store.read(LIKED, []);
|
|
111
|
+
// liked.json is newest first; pages are filled from the oldest end.
|
|
112
|
+
const lk = wire.followersPaging([...liked].reverse(), seen.likedIndex || []);
|
|
113
|
+
const likedPages = await writePages(remote, seen.likedPages, lk.pages.map((items, i) => ({
|
|
114
|
+
id: wire.followersPageId(urls.liked, i + 1), doc: wire.followersPage(urls.liked, i + 1, items, lk.pages.length),
|
|
115
|
+
})));
|
|
116
|
+
const likedHead = await writeHead(remote, seen.likedHead, urls.liked, wire.followersHead(urls.liked, liked.length, lk.pages.length));
|
|
117
|
+
|
|
118
|
+
store.write('published.json', { ...store.read('published.json', {}),
|
|
119
|
+
ownPages, ownIndex: out.index, ownHead, likedPages, likedIndex: lk.index, likedHead });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A head is written only when it changed: most records change a page, not the
|
|
123
|
+
// count or the bounds.
|
|
124
|
+
async function writeHead(remote, before, url, doc) {
|
|
125
|
+
const digest = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
|
|
126
|
+
if (digest !== before) await collection.writeHead(remote, url, doc);
|
|
127
|
+
return digest;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Only the pages that changed, and none past the new end. No rule is written:
|
|
131
|
+
// the private container's owner-only rule is inherited.
|
|
132
|
+
async function writePages(remote, before = {}, pages) {
|
|
133
|
+
const after = {};
|
|
134
|
+
for (const [i, { id, doc }] of pages.entries()) {
|
|
135
|
+
const n = i + 1;
|
|
136
|
+
after[n] = crypto.createHash('sha256').update(JSON.stringify(doc)).digest('hex').slice(0, 16);
|
|
137
|
+
if (before[n] !== after[n]) await collection.writePage(remote, id, doc);
|
|
138
|
+
}
|
|
139
|
+
for (const n of Object.keys(before).map(Number).filter(n => Number.isFinite(n) && n > pages.length)) {
|
|
140
|
+
await collection.dropPage(remote, pages[0].id.replace(/-\d+$/u, `-${n}`));
|
|
141
|
+
}
|
|
142
|
+
return after;
|
|
143
|
+
}
|
|
@@ -9,7 +9,7 @@ import crypto from 'node:crypto';
|
|
|
9
9
|
import * as wire from '../wire.mjs';
|
|
10
10
|
import * as polls from '../polls.mjs';
|
|
11
11
|
import * as podNotes from '../../pod/notes.mjs';
|
|
12
|
-
import { assertDirectAddressed, replyTarget, rowTags } from './notes.mjs';
|
|
12
|
+
import { assertDirectAddressed, replyTarget, rowTags, slugFor } from './notes.mjs';
|
|
13
13
|
|
|
14
14
|
// How long a poll gathers votes before its Question is rewritten. Every vote
|
|
15
15
|
// changes a number other servers re-read, and rewriting per vote would make a
|
|
@@ -22,7 +22,7 @@ const POLL_REWRITE_MS = 10_000;
|
|
|
22
22
|
* voter pick more than one, and `expiresAt` is when voting stops.
|
|
23
23
|
*/
|
|
24
24
|
export async function publishQuestion(publisher, content, { options = [], multiple = false, expiresAt = null,
|
|
25
|
-
inReplyTo = undefined, visibility = 'public', spoilerText = null, sensitive = false } = {}) {
|
|
25
|
+
inReplyTo = undefined, visibility = 'public', spoilerText = null, sensitive = false, slug: wanted = null } = {}) {
|
|
26
26
|
const { urls } = publisher;
|
|
27
27
|
const priv = visibility === 'private' || visibility === 'direct';
|
|
28
28
|
if (priv) {
|
|
@@ -51,7 +51,9 @@ export async function publishQuestion(publisher, content, { options = [], multip
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const published = new Date().toISOString();
|
|
54
|
-
|
|
54
|
+
// The name the client asked for, when it is plain — the outbox door has
|
|
55
|
+
// already told the client the address it names.
|
|
56
|
+
const slug = await slugFor(publisher, priv ? urls.privateNotes : urls.notes, wanted, published);
|
|
55
57
|
const mentions = await publisher._mentionsFor(content, inReplyTo);
|
|
56
58
|
if (visibility === 'direct') assertDirectAddressed(content, mentions);
|
|
57
59
|
const reply = await replyTarget(publisher, inReplyTo, mentions, visibility);
|