fedipod-server 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +22 -6
  2. package/dist/claims.d.ts +8 -0
  3. package/dist/claims.js +10 -0
  4. package/dist/handler.d.ts +13 -0
  5. package/dist/handler.js +56 -11
  6. package/dist/handler.jsonld +8 -0
  7. package/dist/store-pod.js +18 -4
  8. package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
  9. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  10. package/lib/client/masto/accounts.mjs +264 -0
  11. package/lib/client/masto/body.mjs +69 -0
  12. package/lib/client/masto/index.mjs +183 -0
  13. package/lib/client/masto/instance.mjs +104 -0
  14. package/lib/client/masto/media.mjs +133 -0
  15. package/lib/client/masto/oauth.mjs +599 -0
  16. package/lib/client/masto/render.mjs +459 -0
  17. package/lib/client/masto/statuses.mjs +331 -0
  18. package/lib/client/masto/timelines.mjs +316 -0
  19. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  20. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  21. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  22. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  23. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  24. package/lib/{import.mjs → connections/import.mjs} +1 -1
  25. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  26. package/lib/connections/vault.mjs +114 -0
  27. package/lib/core/as2.mjs +170 -0
  28. package/lib/core/contexts/activitystreams.json +379 -0
  29. package/lib/core/contexts/did-v1.json +57 -0
  30. package/lib/core/contexts/fep-5711.json +36 -0
  31. package/lib/core/contexts/gotosocial.json +86 -0
  32. package/lib/core/contexts/identity-v1.json +152 -0
  33. package/lib/core/contexts/index.mjs +45 -0
  34. package/lib/core/contexts/join-lemmy.json +33 -0
  35. package/lib/core/contexts/joinmastodon.json +28 -0
  36. package/lib/core/contexts/map.json +16 -0
  37. package/lib/core/contexts/miscellany.json +19 -0
  38. package/lib/core/contexts/schemaorg.json +8845 -0
  39. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  40. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  41. package/lib/core/contexts/security-multikey-v1.json +35 -0
  42. package/lib/core/contexts/security-v1.json +74 -0
  43. package/lib/core/contexts/webfinger.json +10 -0
  44. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  45. package/lib/core/graphview.mjs +269 -0
  46. package/lib/core/intake/activities.mjs +437 -0
  47. package/lib/core/intake/activity.mjs +240 -0
  48. package/lib/core/intake/channel.mjs +144 -0
  49. package/lib/core/intake/group.mjs +222 -0
  50. package/lib/core/intake/index.mjs +629 -0
  51. package/lib/core/intake/notes.mjs +288 -0
  52. package/lib/core/intake/verify.mjs +142 -0
  53. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  54. package/lib/core/publisher/collections.mjs +229 -0
  55. package/lib/core/publisher/index.mjs +421 -0
  56. package/lib/core/publisher/notes.mjs +188 -0
  57. package/lib/core/publisher/questions.mjs +233 -0
  58. package/lib/core/publisher/restore.mjs +199 -0
  59. package/lib/core/shapes/activitystreams.ttl +129 -0
  60. package/lib/core/shapes/index.mjs +107 -0
  61. package/lib/core/shapes/shapes-text.mjs +13 -0
  62. package/lib/{social.mjs → core/social.mjs} +2 -2
  63. package/lib/{store.mjs → core/store.mjs} +4 -0
  64. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  65. package/lib/device/admin/index.mjs +13 -0
  66. package/lib/device/admin/origins.mjs +35 -0
  67. package/lib/device/admin/routes/connections.mjs +144 -0
  68. package/lib/device/admin/routes/gateway.mjs +199 -0
  69. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  70. package/lib/device/admin/routes/owner.mjs +322 -0
  71. package/lib/device/admin/routes/setup.mjs +393 -0
  72. package/lib/device/admin/routes/social.mjs +188 -0
  73. package/lib/device/admin/server.mjs +95 -0
  74. package/lib/device/admin/static.mjs +244 -0
  75. package/lib/device/admin/surface.mjs +274 -0
  76. package/lib/device/cli/commands/account.mjs +586 -0
  77. package/lib/device/cli/commands/run.mjs +278 -0
  78. package/lib/device/cli/commands/service.mjs +221 -0
  79. package/lib/device/cli/commands/setup.mjs +410 -0
  80. package/lib/device/cli/commands/state.mjs +559 -0
  81. package/lib/device/cli/context.mjs +288 -0
  82. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  83. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  84. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  85. package/lib/{update.mjs → device/update.mjs} +1 -1
  86. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  87. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  88. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  89. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  90. package/lib/server/embed.mjs +405 -0
  91. package/lib/{links.mjs → shared/links.mjs} +1 -1
  92. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  93. package/package.json +1 -1
  94. package/run-agent.mjs +33 -25
  95. package/web/admin/actors.js +145 -0
  96. package/web/admin/common.js +23 -0
  97. package/web/admin/connections.js +112 -0
  98. package/web/admin/gateway.js +111 -0
  99. package/web/admin/group.js +258 -0
  100. package/web/admin/index.html +7 -1
  101. package/web/admin/record.js +378 -0
  102. package/web/admin/setup/index.html +1 -0
  103. package/web/admin/setup/setup.js +2 -13
  104. package/web/admin/upkeep.js +170 -0
  105. package/web/app/README.md +6 -6
  106. package/web/app/admin-facade.mjs +3 -3
  107. package/web/app/agent.mjs +14 -16
  108. package/web/app/atproto-browser.mjs +1 -1
  109. package/web/app/boot.mjs +2 -3
  110. package/web/app/deliver-relay.mjs +1 -1
  111. package/web/app/dist/boot.js +22 -3
  112. package/web/app/dist/boot.js.map +2 -2
  113. package/web/app/dist/sw.js +21913 -5446
  114. package/web/app/dist/sw.js.map +4 -4
  115. package/web/app/fediacct-browser.mjs +1 -1
  116. package/web/app/keys-browser.mjs +27 -4
  117. package/web/app/shims/shapes-text.mjs +8 -0
  118. package/web/app/signup.mjs +2 -3
  119. package/web/app/site/admin/actors.js +145 -0
  120. package/web/app/site/admin/common.js +23 -0
  121. package/web/app/site/admin/connections.js +112 -0
  122. package/web/app/site/admin/gateway.js +111 -0
  123. package/web/app/site/admin/group.js +258 -0
  124. package/web/app/site/admin/index.html +7 -1
  125. package/web/app/site/admin/record.js +378 -0
  126. package/web/app/site/admin/setup/index.html +1 -0
  127. package/web/app/site/admin/setup/setup.js +2 -13
  128. package/web/app/site/admin/upkeep.js +170 -0
  129. package/web/app/site/boot.js +22 -3
  130. package/web/app/site/sw.js +21913 -5446
  131. package/web/app/sw-src.mjs +17 -2
  132. package/lib/admin.mjs +0 -1913
  133. package/lib/embed.mjs +0 -220
  134. package/lib/intake.mjs +0 -1981
  135. package/lib/mastoapi.mjs +0 -2284
  136. package/lib/publisher.mjs +0 -1192
  137. package/web/admin/admin.js +0 -1181
  138. package/web/app/site/admin/admin.js +0 -1181
  139. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  140. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  141. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  142. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  143. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  144. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  145. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  146. /package/lib/{account.mjs → device/account.mjs} +0 -0
  147. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  148. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  149. /package/lib/{home.mjs → device/home.mjs} +0 -0
  150. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  151. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  152. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
@@ -0,0 +1,331 @@
1
+ // statuses.mjs — a status and what a client does to one: compose (a note, a
2
+ // poll, a scheduled post, a reply through a connected account), source and
3
+ // history, read, edit and delete, context, who reacted, pin, bookmarks and
4
+ // favourites, scheduled posts, poll votes, favourite and boost.
5
+
6
+ import crypto from 'node:crypto';
7
+ import * as social from '../../core/social.mjs';
8
+ import { readBody, pollParams, htmlToText } from './body.mjs';
9
+ import {
10
+ MAX_OPTIONS as POLL_MAX_OPTIONS, MAX_OPTION_CHARS as POLL_MAX_OPTION_CHARS,
11
+ MIN_SECONDS as POLL_MIN_SECONDS, MAX_SECONDS as POLL_MAX_SECONDS,
12
+ } from '../../core/polls.mjs';
13
+
14
+ export async function handle(api, ctx) {
15
+ const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
16
+
17
+ if (pathname === '/api/v1/statuses' && req.method === 'POST') {
18
+ const body = await readBody(req);
19
+ if (!body.status) return send(422, { error: 'status text required' });
20
+ const visibility = body.visibility || 'public';
21
+ if (!['public', 'unlisted', 'private', 'direct'].includes(visibility)) {
22
+ return send(422, { error: `unknown visibility "${visibility}"` });
23
+ }
24
+ const inReplyTo = body.in_reply_to_id ? api.store.urlFor(body.in_reply_to_id) : undefined;
25
+ // A reply to a mirrored Bluesky post goes out as a native Bluesky reply
26
+ // — the parent is not an AP object, so there is no AP note to make.
27
+ const bskyParent = inReplyTo && api.store.getStatuses().find(x => x.noteId === inReplyTo && x.kind === 'bsky');
28
+ if (bskyParent) return api.bskyReply(send, body, bskyParent, visibility);
29
+ // A reply to a post a connected account brought us goes out from that
30
+ // account, where the conversation already is.
31
+ const acctParent = inReplyTo && api.store.getStatuses().find(x => x.noteId === inReplyTo && x.kind === 'acct');
32
+ if (acctParent) return api.acctReply(send, body, acctParent, visibility);
33
+ // Private and direct posts live in an owner-only pod container — and
34
+ // only on a pod that provably enforces it.
35
+ if (visibility === 'private' || visibility === 'direct') {
36
+ const ready = await api.agent.publisher.privateReady();
37
+ if (ready !== true) return send(422, { error: ready });
38
+ }
39
+ const spoilerText = String(body.spoiler_text || '').trim() || null;
40
+ const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
41
+ const media = api.store.getMedia();
42
+ const attachments = mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean);
43
+
44
+ const asking = pollParams(body);
45
+ if (asking) {
46
+ // Mastodon's own rules, stated here rather than discovered inside the
47
+ // publisher, so a client gets the reason back on the request it made.
48
+ if (attachments.length) return send(422, { error: 'a poll cannot carry media' });
49
+ if (body.scheduled_at) return send(422, { error: 'a poll cannot be scheduled' });
50
+ if (asking.options.length < 2) return send(422, { error: 'a poll needs at least two options' });
51
+ if (asking.options.length > POLL_MAX_OPTIONS) {
52
+ return send(422, { error: `a poll takes at most ${POLL_MAX_OPTIONS} options` });
53
+ }
54
+ if (asking.options.some(o => o.length > POLL_MAX_OPTION_CHARS)) {
55
+ return send(422, { error: `a poll option is at most ${POLL_MAX_OPTION_CHARS} characters` });
56
+ }
57
+ if (new Set(asking.options).size !== asking.options.length) {
58
+ return send(422, { error: 'a poll\u2019s options must differ from one another' });
59
+ }
60
+ const seconds = asking.expiresIn ?? POLL_MAX_SECONDS;
61
+ if (!Number.isFinite(seconds) || seconds < POLL_MIN_SECONDS || seconds > POLL_MAX_SECONDS) {
62
+ return send(422, {
63
+ error: `a poll runs between ${POLL_MIN_SECONDS} and ${POLL_MAX_SECONDS} seconds`,
64
+ });
65
+ }
66
+ try {
67
+ const q = await api.agent.publisher.publishQuestion(body.status, {
68
+ options: asking.options, multiple: asking.multiple,
69
+ expiresAt: new Date(Date.now() + seconds * 1000).toISOString(),
70
+ inReplyTo, visibility, spoilerText,
71
+ });
72
+ return send(200, api.status(api.store.getStatuses().find(x => x.noteId === q.id)));
73
+ } catch (e) {
74
+ return send(422, { error: e.message });
75
+ }
76
+ }
77
+
78
+ if (body.scheduled_at) {
79
+ // Nothing here publishes it when the time comes (see `scheduling` in the
80
+ // constructor), so saying yes would be losing the post quietly.
81
+ if (!api.scheduling) {
82
+ return send(422, { error: 'this instance cannot schedule posts — it has no process '
83
+ + 'running between now and then to publish one. Post it when you want it sent.' });
84
+ }
85
+ const at = Date.parse(body.scheduled_at);
86
+ if (!Number.isFinite(at) || at < Date.now() + 60_000) {
87
+ return send(422, { error: 'scheduled_at must be at least a minute from now' });
88
+ }
89
+ const sched = api.store.getScheduled();
90
+ const entry = {
91
+ id: crypto.randomBytes(8).toString('hex'), scheduledAt: new Date(at).toISOString(),
92
+ params: { status: body.status, visibility, spoilerText, inReplyTo, attachments },
93
+ };
94
+ sched.push(entry);
95
+ api.store.setScheduled(sched);
96
+ return send(200, api.scheduledJson(entry));
97
+ }
98
+ const note = await api.agent.publisher.publishNote(body.status,
99
+ { inReplyTo, attachments, visibility, spoilerText });
100
+ const s = api.store.getStatuses().find(x => x.noteId === note.id);
101
+ return send(200, api.status(s));
102
+ }
103
+
104
+ // The composer reads the raw text back before an edit.
105
+ const mSource = /^\/api\/v1\/statuses\/([a-f0-9]+)\/source$/.exec(pathname);
106
+ if (mSource && req.method === 'GET') {
107
+ const noteUrl = api.store.urlFor(mSource[1]);
108
+ const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
109
+ if (!s) return send(404, { error: 'Record not found' });
110
+ return send(200, { id: mSource[1], text: s.text ?? htmlToText(s.content || ''), spoiler_text: s.spoiler || '' });
111
+ }
112
+
113
+ // One entry — the current version. Enough for the client's history view;
114
+ // past versions are not kept.
115
+ const mHistory = /^\/api\/v1\/statuses\/([a-f0-9]+)\/history$/.exec(pathname);
116
+ if (mHistory && req.method === 'GET') {
117
+ const noteUrl = api.store.urlFor(mHistory[1]);
118
+ const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
119
+ if (!s) return send(404, { error: 'Record not found' });
120
+ return send(200, [{
121
+ content: s.content || '', spoiler_text: s.spoiler || '', sensitive: !!s.spoiler,
122
+ created_at: s.editedAt || s.published, account: api.account(s.actor),
123
+ media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
124
+ emojis: [], poll: null,
125
+ }]);
126
+ }
127
+
128
+ const mStatus = /^\/api\/v1\/statuses\/([a-f0-9]+)$/.exec(pathname);
129
+ if (mStatus && req.method === 'GET') {
130
+ const { s, wrapped } = api.lookup(mStatus[1]);
131
+ if (!s) return send(404, { error: 'Record not found' });
132
+ return send(200, wrapped ? api.statusOrBoost(s) : api.status(s));
133
+ }
134
+ if (mStatus && req.method === 'PUT') {
135
+ const noteUrl = api.store.urlFor(mStatus[1]);
136
+ const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
137
+ if (!s) return send(404, { error: 'Record not found' });
138
+ if (s.actor !== api.urls.actor) return send(403, { error: 'not your status' });
139
+ const body = await readBody(req);
140
+ if (!body.status) return send(422, { error: 'status text required' });
141
+ const mediaIds = [].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean);
142
+ const media = api.store.getMedia();
143
+ const attachments = mediaIds.length
144
+ ? mediaIds.map(id => media[id] && { id, ...media[id] }).filter(Boolean)
145
+ : null; // null: keep what the post has
146
+ const spoilerText = String(body.spoiler_text || '').trim() || null;
147
+ const patched = await api.agent.publisher.updateNote(s,
148
+ { content: body.status, spoilerText, attachments });
149
+ return send(200, api.status(patched || s));
150
+ }
151
+ if (mStatus && req.method === 'DELETE') {
152
+ const noteUrl = api.store.urlFor(mStatus[1]);
153
+ const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
154
+ if (!s) return send(404, { error: 'Record not found' });
155
+ // A post the owner wrote on a connected account is theirs to delete too.
156
+ // It is deleted where it lives — an AP Delete of ours would address an
157
+ // object that was never ours to speak for.
158
+ const onAcct = s.actor !== api.urls.actor
159
+ ? (api.agent.fediaccts?.list() || []).find(r => r.actorUrl && r.actorUrl === s.actor)
160
+ : null;
161
+ if (s.actor !== api.urls.actor && !onAcct) return send(403, { error: 'not your status' });
162
+ const rendered = api.status(s);
163
+ if (onAcct) {
164
+ const held = (s.sourceAccts || []).find(v => v.acct === onAcct.id);
165
+ if (!held?.remoteId) return send(422, { error: `we do not know where ${onAcct.handle} keeps that post` });
166
+ try {
167
+ await api.agent.fediaccts.api(onAcct.id,
168
+ `/api/v1/statuses/${encodeURIComponent(held.remoteId)}`, { method: 'DELETE' });
169
+ } catch (e) { return send(502, { error: e.message }); }
170
+ api.store.removeStatus(s.noteId);
171
+ return send(200, { ...rendered, text: s.content || '' });
172
+ }
173
+ // 502, because the refusal is the pod's: the client asked correctly and
174
+ // the post is still up. Reporting 200 here is what let a deleted post
175
+ // stay publicly readable with nothing to show for it.
176
+ const gone = await social.deleteNote(api.agent, s);
177
+ if (!gone.ok) return send(502, { error: gone.error });
178
+ return send(200, { ...rendered, text: s.content || '' });
179
+ }
180
+
181
+ // Threads from the mirror's inReplyTo chains.
182
+ const mContext = /^\/api\/v1\/statuses\/([a-f0-9]+)\/context$/.exec(pathname);
183
+ if (mContext) {
184
+ const noteUrl = api.lookup(mContext[1]).s?.noteId;
185
+ const all = api.store.getStatuses();
186
+ const byId = new Map(all.map(s => [s.noteId, s]));
187
+ const ancestors = [];
188
+ let cur = noteUrl && byId.get(noteUrl)?.inReplyTo;
189
+ while (cur && byId.has(cur) && ancestors.length < 40) {
190
+ const s = byId.get(cur);
191
+ ancestors.unshift(s);
192
+ cur = s.inReplyTo;
193
+ }
194
+ const descendants = [];
195
+ const queue = noteUrl ? [noteUrl] : [];
196
+ while (queue.length && descendants.length < 60) {
197
+ const id = queue.shift();
198
+ for (const s of all) if (s.inReplyTo === id) { descendants.push(s); queue.push(s.noteId); }
199
+ }
200
+ return send(200, {
201
+ ancestors: ancestors.map(s => api.status(s, { all })),
202
+ descendants: descendants.map(s => api.status(s, { all })),
203
+ });
204
+ }
205
+
206
+ // Who reacted: only what our own store witnessed, so the lists are the
207
+ // reactions we were notified of, not the whole fediverse's count.
208
+ const mWho = /^\/api\/v1\/statuses\/([a-f0-9]+)\/(reblogged_by|favourited_by)$/.exec(pathname);
209
+ if (mWho && req.method === 'GET') {
210
+ const noteUrl = api.store.urlFor(mWho[1]);
211
+ if (!noteUrl) return send(404, { error: 'Record not found' });
212
+ const type = mWho[2] === 'reblogged_by' ? 'reblog' : 'favourite';
213
+ const actors = [...new Set(api.store.getNotifications()
214
+ .filter(n => n.noteId === noteUrl && n.type === type).map(n => n.actor))];
215
+ return send(200, actors.map(a => api.account(a)));
216
+ }
217
+
218
+ // Pinning republishes the featured collection, so a visitor's server
219
+ // shows the pins too; the actor document names the collection.
220
+ const mPin = /^\/api\/v1\/statuses\/([a-f0-9]+)\/(pin|unpin)$/.exec(pathname);
221
+ if (mPin && req.method === 'POST') {
222
+ const noteUrl = api.store.urlFor(mPin[1]);
223
+ const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
224
+ if (!s) return send(404, { error: 'Record not found' });
225
+ if (s.actor !== api.urls.actor) return send(403, { error: 'not your status' });
226
+ const updated = await social.pinStatus(api.agent, s, mPin[2] === 'pin');
227
+ return send(200, api.status(updated));
228
+ }
229
+
230
+ if (pathname === '/api/v1/reports' && req.method === 'POST') {
231
+ return send(422, {
232
+ error: 'this is your own single-user server, so there is no moderation team to receive a report. Blocking the account is the action that takes effect here.',
233
+ });
234
+ }
235
+
236
+ // Bookmarks are this machine's own list: nothing federates, nothing is
237
+ // told, which is what a bookmark means everywhere.
238
+ const mBookmark = /^\/api\/v1\/statuses\/([a-f0-9]+)\/(bookmark|unbookmark)$/.exec(pathname);
239
+ if (mBookmark && req.method === 'POST') {
240
+ const noteUrl = api.store.urlFor(mBookmark[1]);
241
+ const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
242
+ if (!s) return send(404, { error: 'Record not found' });
243
+ const updated = api.store.updateStatus(s.noteId, { bookmarked: mBookmark[2] === 'bookmark' });
244
+ return send(200, api.status(updated || s));
245
+ }
246
+ if (pathname === '/api/v1/bookmarks' || pathname === '/api/v1/favourites') {
247
+ const key = pathname.endsWith('bookmarks') ? 'bookmarked' : 'favourited';
248
+ const all = api.store.getStatuses();
249
+ const items = all.filter(s => s[key])
250
+ .sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
251
+ const { items: page, headers } = api.page(items, url);
252
+ return send(200, page.map(s => api.status(s, { all })), headers);
253
+ }
254
+
255
+ if (pathname === '/api/v1/scheduled_statuses' && req.method === 'GET') {
256
+ return send(200, api.store.getScheduled().map(e => api.scheduledJson(e)));
257
+ }
258
+ const mSched = /^\/api\/v1\/scheduled_statuses\/([a-f0-9]+)$/.exec(pathname);
259
+ if (mSched) {
260
+ const sched = api.store.getScheduled();
261
+ const e = sched.find(x => x.id === mSched[1]);
262
+ if (!e) return send(404, { error: 'Record not found' });
263
+ if (req.method === 'DELETE') {
264
+ api.store.setScheduled(sched.filter(x => x.id !== mSched[1]));
265
+ return send(200, {});
266
+ }
267
+ if (req.method === 'PUT') {
268
+ const body = await readBody(req);
269
+ const at = Date.parse(body.scheduled_at || '');
270
+ if (!Number.isFinite(at)) return send(422, { error: 'scheduled_at required' });
271
+ e.scheduledAt = new Date(at).toISOString();
272
+ api.store.setScheduled(sched);
273
+ }
274
+ return send(200, api.scheduledJson(e));
275
+ }
276
+
277
+ const mPoll = /^\/api\/v1\/polls\/([a-f0-9]+)(\/votes)?$/.exec(pathname);
278
+ if (mPoll) {
279
+ const noteUrl = api.store.urlFor(mPoll[1]);
280
+ const s = noteUrl && api.store.getStatuses().find(x => x.noteId === noteUrl);
281
+ if (!s?.poll) return send(404, { error: 'Record not found' });
282
+ if (mPoll[2] && req.method === 'POST') {
283
+ const body = await readBody(req);
284
+ const choices = [].concat(body.choices || body['choices[]'] || []).map(Number)
285
+ .filter(Number.isInteger);
286
+ if (!choices.length) return send(422, { error: 'choices required' });
287
+ const r = await social.votePoll(api.agent, s, choices);
288
+ if (!r.ok) return send(422, { error: r.error });
289
+ return send(200, api.pollJson(api.store.getStatuses().find(x => x.noteId === noteUrl)));
290
+ }
291
+ return send(200, api.pollJson(s));
292
+ }
293
+
294
+ const mAction = /^\/api\/v1\/statuses\/([a-f0-9]+)\/(favourite|unfavourite|reblog|unreblog)$/.exec(pathname);
295
+ if (mAction && req.method === 'POST') {
296
+ // Acting on a carried row acts on the post itself, as Mastodon does.
297
+ const { s } = api.lookup(mAction[1]);
298
+ if (!s) return send(404, { error: 'Record not found' });
299
+ // A mirrored Bluesky post is not an AP object — a Like or Announce has
300
+ // nothing to address. The interaction goes to Bluesky instead, as the
301
+ // native record every Bluesky app writes.
302
+ if (s.kind === 'bsky') {
303
+ const at = api.agent.atproto;
304
+ if (!at?.connected()) return send(422, { error: 'this is a Bluesky post — no Bluesky account is connected to act from' });
305
+ try {
306
+ let patch = null;
307
+ if (mAction[2] === 'favourite' && !s.favourited) {
308
+ patch = { favourited: true, bskyLike: (await at.like(s.noteId, s.cid || null)).uri };
309
+ } else if (mAction[2] === 'unfavourite' && s.favourited) {
310
+ if (s.bskyLike) await at.deleteCrossPost(s.bskyLike);
311
+ patch = { favourited: false, bskyLike: undefined };
312
+ } else if (mAction[2] === 'reblog' && !s.reblogged) {
313
+ patch = { reblogged: true, bskyRepost: (await at.repost(s.noteId, s.cid || null)).uri };
314
+ } else if (mAction[2] === 'unreblog' && s.reblogged) {
315
+ if (s.bskyRepost) await at.deleteCrossPost(s.bskyRepost);
316
+ patch = { reblogged: false, bskyRepost: undefined };
317
+ }
318
+ return send(200, api.status(patch ? (api.store.updateStatus(s.noteId, patch) || s) : s));
319
+ } catch (e) { return send(422, { error: e.message }); }
320
+ }
321
+ // A post that reached us through a connected account is not ours to
322
+ // address as the pod actor. Our own posts and our own inbox's timeline
323
+ // keep the AP path even when a connected account also saw them: where
324
+ // the pod identity has standing, it is the one that acts.
325
+ if (s.kind === 'acct') return api.acctAction(send, s, mAction[2]);
326
+ const updated = await social[mAction[2]](api.agent, s);
327
+ return send(200, api.status(updated || s));
328
+ }
329
+
330
+ return false;
331
+ }
@@ -0,0 +1,316 @@
1
+ // timelines.mjs — what a client reads by the page: the home and public
2
+ // timelines, hashtags and the tag feed, lists and their timelines, the v2
3
+ // filters, conversations, notifications, and search.
4
+
5
+ import crypto from 'node:crypto';
6
+ import * as social from '../../core/social.mjs';
7
+ import { sanitizeHtml } from '../../core/wire.mjs';
8
+ import { authorOf } from '../../core/intake/index.mjs';
9
+ import { readBody } from './body.mjs';
10
+
11
+ export async function handle(api, ctx) {
12
+ const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
13
+
14
+ // home + public = everything known (follows, boosts, tag feed, own);
15
+ // public?local=true = own posts; trends/statuses = the same activity (a
16
+ // single-actor instance has no firehose — what it knows IS its public
17
+ // face). Sorted by publish time so tag-feed backfill interleaves.
18
+ if (pathname === '/api/v1/timelines/home' || pathname === '/api/v1/timelines/public'
19
+ || pathname === '/api/v1/trends/statuses') {
20
+ const localOnly = pathname === '/api/v1/timelines/public' && url.searchParams.get('local') === 'true';
21
+ // The UNFILTERED list, both as the source and as the reply-count corpus:
22
+ // a reply can be a mention, and mentions are filtered out of timelines
23
+ // below — counting against the filtered set would undercount them.
24
+ const all = api.store.getStatuses();
25
+ const muted = new Set(api.store.getMuted().actors);
26
+ let items = all
27
+ // search ingests and strangers' mentions stay out of the timelines
28
+ // (mentions remain in notifications and /api/v1/statuses); direct
29
+ // posts belong to the conversations view, not a timeline; and the
30
+ // local/public view shows only what is actually public-facing.
31
+ .filter(s => s.kind !== 'remote' && s.kind !== 'mention')
32
+ .filter(s => !s.direct && s.visibility !== 'direct')
33
+ .filter(s => !muted.has(s.actor) && !muted.has(s.via))
34
+ .filter(s => !localOnly || (s.kind === 'post' && s.visibility !== 'private'))
35
+ .sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
36
+ const { items: page, headers } = api.page(items, url);
37
+ return send(200, page.map(s => api.statusOrBoost(s, { all })), headers);
38
+ }
39
+
40
+ // --- hashtags: the client's Followed Hashtags surface drives the tag feed ---
41
+ // followed_tags is what the feed currently pulls; follow/unfollow add and
42
+ // remove a hashtag (unfollow the last and the topical feed goes quiet); the
43
+ // tag timeline is the mirrored notes carrying that tag.
44
+ if (pathname === '/api/v1/followed_tags') {
45
+ const tags = api.agent.tagfeed?.config().tags || [];
46
+ return send(200, tags.map(t => api.tagObject(t, true, req)));
47
+ }
48
+ const mTagFollow = pathname.match(/^\/api\/v1\/tags\/([^/]+)\/(follow|unfollow)$/);
49
+ if (mTagFollow && req.method === 'POST') {
50
+ const name = decodeURIComponent(mTagFollow[1]).replace(/^#/, '').toLowerCase();
51
+ const follow = mTagFollow[2] === 'follow';
52
+ const tf = api.agent.tagfeed;
53
+ if (tf) {
54
+ const cur = tf.config().tags;
55
+ tf.setConfig({ tags: follow ? [...new Set([...cur, name])] : cur.filter(t => t !== name) });
56
+ }
57
+ return send(200, api.tagObject(name, follow, req));
58
+ }
59
+ const mTagGet = pathname.match(/^\/api\/v1\/tags\/([^/]+)$/);
60
+ if (mTagGet && req.method === 'GET') {
61
+ const name = decodeURIComponent(mTagGet[1]).replace(/^#/, '').toLowerCase();
62
+ const following = (api.agent.tagfeed?.config().tags || []).includes(name);
63
+ return send(200, api.tagObject(name, following, req));
64
+ }
65
+ const mTagTl = pathname.match(/^\/api\/v1\/timelines\/tag\/([^/]+)$/);
66
+ if (mTagTl && req.method === 'GET') {
67
+ const name = decodeURIComponent(mTagTl[1]).replace(/^#/, '').toLowerCase();
68
+ const all = api.store.getStatuses();
69
+ const items = all.filter(s => s.kind === 'tag' && s.tag === name)
70
+ .sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
71
+ const { items: page, headers } = api.page(items, url);
72
+ return send(200, page.map(s => api.statusOrBoost(s, { all })), headers);
73
+ }
74
+
75
+ // Lists, filters and scheduled posts are the client's own arrangements —
76
+ // kept in local state, nothing federates.
77
+ const listJson = (l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || 'list', exclusive: false });
78
+ if (pathname === '/api/v1/lists' && req.method === 'GET') {
79
+ return send(200, api.store.getLists().map(listJson));
80
+ }
81
+ if (pathname === '/api/v1/lists' && req.method === 'POST') {
82
+ const body = await readBody(req);
83
+ const title = String(body.title || '').trim();
84
+ if (!title) return send(422, { error: 'a title is required' });
85
+ const lists = api.store.getLists();
86
+ const l = { id: crypto.randomBytes(8).toString('hex'), title, repliesPolicy: body.replies_policy || 'list', members: [] };
87
+ lists.push(l);
88
+ api.store.setLists(lists);
89
+ return send(200, listJson(l));
90
+ }
91
+ const mList = /^\/api\/v1\/lists\/([a-f0-9]+)$/.exec(pathname);
92
+ if (mList) {
93
+ const lists = api.store.getLists();
94
+ const l = lists.find(x => x.id === mList[1]);
95
+ if (!l) return send(404, { error: 'Record not found' });
96
+ if (req.method === 'DELETE') {
97
+ api.store.setLists(lists.filter(x => x.id !== mList[1]));
98
+ return send(200, {});
99
+ }
100
+ if (req.method === 'PUT') {
101
+ const body = await readBody(req);
102
+ if (body.title) l.title = String(body.title);
103
+ if (body.replies_policy) l.repliesPolicy = body.replies_policy;
104
+ api.store.setLists(lists);
105
+ }
106
+ return send(200, listJson(l));
107
+ }
108
+ const mListAcc = /^\/api\/v1\/lists\/([a-f0-9]+)\/accounts$/.exec(pathname);
109
+ if (mListAcc) {
110
+ const lists = api.store.getLists();
111
+ const l = lists.find(x => x.id === mListAcc[1]);
112
+ if (!l) return send(404, { error: 'Record not found' });
113
+ if (req.method === 'GET') return send(200, (l.members || []).map(a => api.account(a)));
114
+ const body = await readBody(req).catch(() => ({}));
115
+ const ids = [].concat(body.account_ids || body['account_ids[]'] || url.searchParams.getAll('account_ids[]')).filter(Boolean);
116
+ const actors = ids.map(id => api.store.urlFor(id)).filter(Boolean);
117
+ if (req.method === 'POST') l.members = [...new Set([...(l.members || []), ...actors])];
118
+ if (req.method === 'DELETE') l.members = (l.members || []).filter(a => !actors.includes(a));
119
+ api.store.setLists(lists);
120
+ return send(200, {});
121
+ }
122
+ const mListTl = /^\/api\/v1\/timelines\/list\/([a-f0-9]+)$/.exec(pathname);
123
+ if (mListTl) {
124
+ const l = api.store.getLists().find(x => x.id === mListTl[1]);
125
+ if (!l) return send(404, { error: 'Record not found' });
126
+ const members = new Set(l.members || []);
127
+ const all = api.store.getStatuses();
128
+ const items = all.filter(s => members.has(s.actor) || members.has(s.via))
129
+ .sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
130
+ const { items: page, headers } = api.page(items, url);
131
+ return send(200, page.map(s => api.statusOrBoost(s, { all })), headers);
132
+ }
133
+
134
+ // v2 filters: stored, served, and APPLIED — every status carries what it
135
+ // matched in `filtered` (see filtersFor), because no client matches
136
+ // keywords for itself.
137
+ const filterJson = (f) => ({
138
+ id: f.id, title: f.title, context: f.context || ['home'],
139
+ expires_at: f.expiresAt || null, filter_action: f.action || 'warn',
140
+ keywords: (f.keywords || []).map((k, i) => ({ id: `${f.id}-${i}`, keyword: k.keyword, whole_word: !!k.wholeWord })),
141
+ statuses: [],
142
+ });
143
+ const keywordsOf = (attrs) => [].concat(attrs || [])
144
+ .filter(k => k?.keyword && !(k._destroy === true || k._destroy === 'true'))
145
+ .map(k => ({ keyword: String(k.keyword), wholeWord: k.whole_word === true || k.whole_word === 'true' }));
146
+ if (pathname === '/api/v2/filters' && req.method === 'GET') {
147
+ return send(200, api.store.getFilters().map(filterJson));
148
+ }
149
+ if (pathname === '/api/v2/filters' && req.method === 'POST') {
150
+ const body = await readBody(req);
151
+ const title = String(body.title || '').trim();
152
+ if (!title) return send(422, { error: 'a title is required' });
153
+ const filters = api.store.getFilters();
154
+ const f = {
155
+ id: crypto.randomBytes(8).toString('hex'), title,
156
+ context: [].concat(body.context || ['home']),
157
+ action: body.filter_action || 'warn', expiresAt: null,
158
+ keywords: keywordsOf(body.keywords_attributes),
159
+ };
160
+ filters.push(f);
161
+ api.store.setFilters(filters);
162
+ return send(200, filterJson(f));
163
+ }
164
+ const mFilter = /^\/api\/v2\/filters\/([a-f0-9]+)$/.exec(pathname);
165
+ if (mFilter) {
166
+ const filters = api.store.getFilters();
167
+ const f = filters.find(x => x.id === mFilter[1]);
168
+ if (!f) return send(404, { error: 'Record not found' });
169
+ if (req.method === 'DELETE') {
170
+ api.store.setFilters(filters.filter(x => x.id !== mFilter[1]));
171
+ return send(200, {});
172
+ }
173
+ if (req.method === 'PUT') {
174
+ const body = await readBody(req);
175
+ if (body.title) f.title = String(body.title);
176
+ if (body.context) f.context = [].concat(body.context);
177
+ if (body.filter_action) f.action = body.filter_action;
178
+ if (body.keywords_attributes) f.keywords = keywordsOf(body.keywords_attributes);
179
+ api.store.setFilters(filters);
180
+ }
181
+ return send(200, filterJson(f));
182
+ }
183
+
184
+ // Conversations: direct posts, grouped by who is in them.
185
+ if (pathname === '/api/v1/conversations' && req.method === 'GET') {
186
+ const all = api.store.getStatuses();
187
+ const me = api.urls.actor;
188
+ const convos = new Map();
189
+ for (const s of all) {
190
+ if (!(s.direct || s.visibility === 'direct')) continue;
191
+ const others = [...new Set([
192
+ ...(s.actor !== me ? [s.actor] : []),
193
+ ...((s.mentions || []).map(m => m.href).filter(a => a && a !== me)),
194
+ ])];
195
+ const key = others.sort().join(' ') || me;
196
+ const c = convos.get(key) || { accounts: new Set(), last: s };
197
+ for (const o of others) c.accounts.add(o);
198
+ if (String(s.published || '') > String(c.last.published || '')) c.last = s;
199
+ convos.set(key, c);
200
+ }
201
+ const items = [...convos.entries()]
202
+ .sort((a, b) => String(b[1].last.published || '').localeCompare(String(a[1].last.published || '')));
203
+ return send(200, items.map(([key, c]) => ({
204
+ id: api.store.idFor('conversation:' + key),
205
+ unread: false,
206
+ accounts: [...(c.accounts.size ? c.accounts : [me])].map(a => api.account(a)),
207
+ last_status: api.status(c.last, { all }),
208
+ })));
209
+ }
210
+
211
+ if (pathname === '/api/v1/notifications') {
212
+ const limit = Math.min(Number(url.searchParams.get('limit')) || 30, 60);
213
+ const q = url.searchParams;
214
+ // Newest first, which is what every id rule below assumes.
215
+ let items = api.store.getNotifications();
216
+
217
+ // Which KINDS. A client that asks for mentions and is handed favourites
218
+ // and boosts renders them as mentions — Phanpy's Mentions column did
219
+ // exactly that, and re-pilled on every poll. Both spellings: clients send
220
+ // `types[]=mention` and some send bare `types=mention`.
221
+ const listParam = (name) => {
222
+ const all = [...q.getAll(`${name}[]`), ...q.getAll(name)]
223
+ .flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean);
224
+ return all.length ? new Set(all) : null;
225
+ };
226
+ const want = listParam('types');
227
+ const skip = listParam('exclude_types');
228
+ // Mastodon spells the type with an underscore; ours is stored with a
229
+ // hyphen, and notification() translates on the way out — so match on what
230
+ // the client would have been given, not on what is on disk.
231
+ const shown = (n) => api.notificationType(n.type);
232
+ if (want) items = items.filter((n) => want.has(shown(n)));
233
+ if (skip) items = items.filter((n) => !skip.has(shown(n)));
234
+
235
+ // Which SLICE. `max_id` walks backwards into history; `since_id` and
236
+ // `min_id` both mean "newer than this", which is how a client asks
237
+ // "anything since I last looked?". Ignoring them meant every poll
238
+ // returned the whole list, so the client always saw unread items and the
239
+ // bell never went out.
240
+ const cut = (id, keepNewer) => {
241
+ const i = items.findIndex((n) => n.id === id);
242
+ if (i < 0) return;
243
+ items = keepNewer ? items.slice(0, i) : items.slice(i + 1);
244
+ };
245
+ const maxId = q.get('max_id');
246
+ if (maxId) cut(maxId, false);
247
+ const sinceId = q.get('since_id') || q.get('min_id');
248
+ if (sinceId) cut(sinceId, true);
249
+ // min_id asks for the ones IMMEDIATELY newer, i.e. the oldest end of
250
+ // what is newer; since_id asks for the newest. Only the window differs.
251
+ const page = q.get('min_id') && !q.get('since_id')
252
+ ? items.slice(Math.max(0, items.length - limit))
253
+ : items.slice(0, limit);
254
+
255
+ // A client pages by following these rather than by guessing ids.
256
+ if (page.length) {
257
+ const base = `${api.scheme || (req.socket?.encrypted ? 'https' : 'http')}://${req.headers.host}${pathname}`;
258
+ const link = (params) => {
259
+ const u = new URL(base);
260
+ for (const [k, v] of q) if (k !== 'max_id' && k !== 'since_id' && k !== 'min_id') u.searchParams.append(k, v);
261
+ for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
262
+ return u.href;
263
+ };
264
+ return send(200, page.map((n) => api.notification(n)), {
265
+ link: `<${link({ max_id: page[page.length - 1].id })}>; rel="next", `
266
+ + `<${link({ min_id: page[0].id })}>; rel="prev"`,
267
+ });
268
+ }
269
+ return send(200, []);
270
+ }
271
+
272
+ // Search: @user@host → webfinger resolve; URL → actor or note ingest;
273
+ // plain text → local mirror + actor-cache scan.
274
+ if (pathname === '/api/v2/search' || pathname === '/api/v1/search') {
275
+ const q = String(url.searchParams.get('q') || '').trim();
276
+ const type = url.searchParams.get('type');
277
+ const out = { accounts: [], statuses: [], hashtags: [] };
278
+ const asHandle = /^@?[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(q);
279
+ if (asHandle && type !== 'statuses') {
280
+ try {
281
+ const doc = await social.resolveHandle(api.agent, q);
282
+ out.accounts.push(api.account(doc.id));
283
+ } catch (e) { api.log(`search resolve ${q}: ${e.message}`); }
284
+ } else if (/^https?:\/\//.test(q)) {
285
+ const doc = await api.agent.intake.fetchAP(q).catch(() => null);
286
+ if (doc?.type === 'Person' && doc.id) out.accounts.push(api.account(doc.id));
287
+ else if (doc?.type === 'Note' && doc.id) {
288
+ let s = api.store.getStatuses().find(x => x.noteId === doc.id);
289
+ // Third site of the same rule: the note's own origin has to vouch for
290
+ // the author before we store one. The owner chose the URL, but the
291
+ // document at it still names whoever it likes.
292
+ const author = authorOf(doc);
293
+ if (!s && author) {
294
+ s = {
295
+ noteId: doc.id, actor: author, content: sanitizeHtml(doc.content),
296
+ published: doc.published, inReplyTo: doc.inReplyTo, kind: 'remote',
297
+ };
298
+ api.store.addStatus(s);
299
+ }
300
+ if (s) out.statuses.push(api.status(s));
301
+ }
302
+ } else if (q) {
303
+ const needle = q.toLowerCase();
304
+ if (type !== 'accounts') {
305
+ const all = api.store.getStatuses();
306
+ out.statuses = all
307
+ .filter(s => (s.content || '').toLowerCase().includes(needle)).slice(0, 20)
308
+ .map(s => api.status(s, { all }));
309
+ }
310
+ if (type !== 'statuses') out.accounts = await api.accountSearch(q);
311
+ }
312
+ return send(200, out);
313
+ }
314
+
315
+ return false;
316
+ }
@@ -16,7 +16,7 @@ const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
16
16
  const MAX_SOCKETS = 20; // matches the client-token cap
17
17
  const MAX_BUFFERED_BYTES = 1024 * 1024; // a consumer this far behind is gone
18
18
 
19
- import { checkRequest } from './guard.mjs';
19
+ import { checkRequest } from '../shared/guard.mjs';
20
20
 
21
21
  export class Streaming {
22
22
  constructor({ masto, log = console.log, allowed = null, gate = null, gateOptional = false }) {
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // Config in fediacctfeed.json: { intervalMin, accounts: { <id>: marks } }.
13
13
 
14
- import { sanitizeHtml } from './wire.mjs';
14
+ import { sanitizeHtml } from '../core/wire.mjs';
15
15
 
16
16
  const DEFAULTS = { intervalMin: 5 };
17
17
  const PER_SWEEP = 40;