fedipod 0.19.0 → 1.2.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 +104 -134
- package/architecture.md +41 -0
- package/bin/fedipod.mjs +45 -2253
- package/browser.svg +72 -0
- package/cli.md +0 -4
- package/gateway.md +160 -0
- package/groups.md +0 -6
- package/gui.md +7 -11
- package/installed-agent.md +97 -0
- package/lib/{c2s.mjs → client/c2s.mjs} +8 -3
- package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
- package/lib/client/masto/accounts.mjs +264 -0
- package/lib/client/masto/body.mjs +69 -0
- package/lib/client/masto/index.mjs +183 -0
- package/lib/client/masto/instance.mjs +104 -0
- package/lib/client/masto/media.mjs +133 -0
- package/lib/client/masto/oauth.mjs +599 -0
- package/lib/client/masto/render.mjs +459 -0
- package/lib/client/masto/statuses.mjs +331 -0
- package/lib/client/masto/timelines.mjs +316 -0
- package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
- package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
- package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
- package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
- package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
- package/lib/{import.mjs → connections/import.mjs} +1 -1
- package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
- package/lib/connections/vault.mjs +114 -0
- package/lib/core/as2.mjs +124 -0
- package/lib/core/contexts/activitystreams.json +379 -0
- package/lib/core/contexts/did-v1.json +57 -0
- package/lib/core/contexts/fep-5711.json +36 -0
- package/lib/core/contexts/gotosocial.json +86 -0
- package/lib/core/contexts/identity-v1.json +152 -0
- package/lib/core/contexts/index.mjs +45 -0
- package/lib/core/contexts/join-lemmy.json +33 -0
- package/lib/core/contexts/joinmastodon.json +28 -0
- package/lib/core/contexts/map.json +16 -0
- package/lib/core/contexts/miscellany.json +19 -0
- package/lib/core/contexts/schemaorg.json +8845 -0
- package/lib/core/contexts/security-data-integrity-v1.json +78 -0
- package/lib/core/contexts/security-data-integrity-v2.json +81 -0
- package/lib/core/contexts/security-multikey-v1.json +35 -0
- package/lib/core/contexts/security-v1.json +74 -0
- package/lib/core/contexts/webfinger.json +10 -0
- package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
- package/lib/core/intake/activities.mjs +437 -0
- package/lib/core/intake/activity.mjs +240 -0
- package/lib/core/intake/channel.mjs +144 -0
- package/lib/core/intake/group.mjs +222 -0
- package/lib/core/intake/index.mjs +629 -0
- package/lib/core/intake/notes.mjs +288 -0
- package/lib/core/intake/verify.mjs +141 -0
- package/lib/{keys.mjs → core/keys.mjs} +1 -1
- package/lib/core/publisher/collections.mjs +229 -0
- package/lib/core/publisher/index.mjs +421 -0
- package/lib/core/publisher/notes.mjs +188 -0
- package/lib/core/publisher/questions.mjs +233 -0
- package/lib/core/publisher/restore.mjs +196 -0
- package/lib/core/shapes/activitystreams.ttl +129 -0
- package/lib/core/shapes/index.mjs +107 -0
- package/lib/core/shapes/shapes-text.mjs +13 -0
- package/lib/{social.mjs → core/social.mjs} +2 -2
- package/lib/{store.mjs → core/store.mjs} +4 -0
- package/lib/{wire.mjs → core/wire.mjs} +2 -2
- package/lib/device/admin/index.mjs +13 -0
- package/lib/device/admin/origins.mjs +35 -0
- package/lib/device/admin/routes/connections.mjs +144 -0
- package/lib/device/admin/routes/gateway.mjs +199 -0
- package/lib/device/admin/routes/lifecycle.mjs +191 -0
- package/lib/device/admin/routes/owner.mjs +322 -0
- package/lib/device/admin/routes/setup.mjs +393 -0
- package/lib/device/admin/routes/social.mjs +188 -0
- package/lib/device/admin/server.mjs +95 -0
- package/lib/device/admin/static.mjs +244 -0
- package/lib/device/admin/surface.mjs +274 -0
- package/lib/device/cli/commands/account.mjs +586 -0
- package/lib/device/cli/commands/run.mjs +278 -0
- package/lib/device/cli/commands/service.mjs +221 -0
- package/lib/device/cli/commands/setup.mjs +410 -0
- package/lib/device/cli/commands/state.mjs +559 -0
- package/lib/device/cli/context.mjs +288 -0
- package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
- package/lib/{remote.mjs → device/remote.mjs} +3 -3
- package/lib/{setup.mjs → device/setup.mjs} +3 -3
- package/lib/{update.mjs → device/update.mjs} +1 -1
- package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
- package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
- package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
- package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
- package/lib/{embed.mjs → server/embed.mjs} +131 -22
- package/lib/{links.mjs → shared/links.mjs} +1 -1
- package/lib/{ua.mjs → shared/ua.mjs} +1 -1
- package/package.json +16 -2
- package/run-agent.mjs +33 -25
- package/scripts/build-app.mjs +4 -0
- package/scripts/check-pod-calls.mjs +8 -2
- package/scripts/refresh-contexts.mjs +39 -0
- package/web/admin/actors.js +145 -0
- package/web/admin/common.js +23 -0
- package/web/admin/connections.js +112 -0
- package/web/admin/gateway.js +111 -0
- package/web/admin/group.js +258 -0
- package/web/admin/index.html +7 -1
- package/web/admin/record.js +378 -0
- package/web/admin/setup/index.html +1 -0
- package/web/admin/setup/setup.js +2 -13
- package/web/admin/upkeep.js +170 -0
- package/web/app/README.md +6 -6
- package/web/app/admin-facade.mjs +3 -3
- package/web/app/agent.mjs +12 -12
- package/web/app/atproto-browser.mjs +1 -1
- package/web/app/deliver-relay.mjs +1 -1
- package/web/app/dist/sw.js +21684 -5415
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/fediacct-browser.mjs +1 -1
- package/web/app/shims/shapes-text.mjs +8 -0
- package/web/app/site/admin/actors.js +145 -0
- package/web/app/site/admin/common.js +23 -0
- package/web/app/site/admin/connections.js +112 -0
- package/web/app/site/admin/gateway.js +111 -0
- package/web/app/site/admin/group.js +258 -0
- package/web/app/site/admin/index.html +7 -1
- package/web/app/site/admin/record.js +378 -0
- package/web/app/site/admin/setup/index.html +1 -0
- package/web/app/site/admin/setup/setup.js +2 -13
- package/web/app/site/admin/upkeep.js +170 -0
- package/web/app/site/sw.js +21684 -5415
- package/web/app/sw-src.mjs +17 -2
- package/lib/admin.mjs +0 -1913
- package/lib/intake.mjs +0 -1981
- package/lib/mastoapi.mjs +0 -2284
- package/lib/publisher.mjs +0 -1192
- package/web/admin/admin.js +0 -1181
- package/web/app/site/admin/admin.js +0 -1181
- /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
- /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
- /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
- /package/lib/{lease.mjs → core/lease.mjs} +0 -0
- /package/lib/{polls.mjs → core/polls.mjs} +0 -0
- /package/lib/{proof.mjs → core/proof.mjs} +0 -0
- /package/lib/{storage.mjs → core/storage.mjs} +0 -0
- /package/lib/{account.mjs → device/account.mjs} +0 -0
- /package/lib/{certs.mjs → device/certs.mjs} +0 -0
- /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
- /package/lib/{home.mjs → device/home.mjs} +0 -0
- /package/lib/{ports.mjs → device/ports.mjs} +0 -0
- /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
- /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// accounts.mjs — the account endpoints: the owner's credentials and profile
|
|
2
|
+
// editor, follow requests, markers, relationships, search and lookup, block
|
|
3
|
+
// and mute, follow and unfollow, an account and its lists and statuses, and
|
|
4
|
+
// the web-push subscription a client login holds.
|
|
5
|
+
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import * as podMedia from '../../pod/media.mjs';
|
|
8
|
+
import * as social from '../../core/social.mjs';
|
|
9
|
+
import { readBody } from './body.mjs';
|
|
10
|
+
import { readMultipart } from './media.mjs';
|
|
11
|
+
|
|
12
|
+
export async function handle(api, ctx) {
|
|
13
|
+
const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
|
|
14
|
+
|
|
15
|
+
if (pathname === '/api/v1/accounts/verify_credentials') {
|
|
16
|
+
const cfg0 = api.store.getConfig() || {};
|
|
17
|
+
return send(200, {
|
|
18
|
+
...api.selfAccount(),
|
|
19
|
+
// `source` is what the editor fills its inputs from: the raw text it
|
|
20
|
+
// will send back, not the HTML the profile renders.
|
|
21
|
+
source: {
|
|
22
|
+
privacy: 'public', sensitive: false, language: 'en',
|
|
23
|
+
note: cfg0.summary || '',
|
|
24
|
+
fields: (cfg0.fields || []).map(f => ({ name: f.name, value: f.value })),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The profile editor. Everything it can send is carried: the name and bio,
|
|
30
|
+
// both pictures, and the extra fields. An avatar or header arrives as file
|
|
31
|
+
// bytes, so it goes to the pod's media container first and the actor gets
|
|
32
|
+
// the URL — the same path a posted attachment takes.
|
|
33
|
+
if (pathname === '/api/v1/accounts/update_credentials') {
|
|
34
|
+
if (req.method !== 'PATCH' && req.method !== 'POST') return send(405, { error: 'PATCH expected' });
|
|
35
|
+
const ct = String(req.headers['content-type'] || '');
|
|
36
|
+
// readBody already covers JSON and urlencoded; only the file case differs.
|
|
37
|
+
let form = {}, files = {};
|
|
38
|
+
if (ct.includes('multipart/form-data')) ({ fields: form, files } = await readMultipart(req));
|
|
39
|
+
else form = await readBody(req);
|
|
40
|
+
|
|
41
|
+
const cfg = { ...api.store.getConfig() };
|
|
42
|
+
const putImage = async (f) => {
|
|
43
|
+
const ext = (f.filename || '').includes('.')
|
|
44
|
+
? f.filename.split('.').pop().replace(/[^\w]/g, '') : 'bin';
|
|
45
|
+
const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
|
|
46
|
+
const url = api.urls.media + slug;
|
|
47
|
+
await api.agent.publisher.ensureMediaContainer();
|
|
48
|
+
await podMedia.write(api.agent.remote, url, f.data, f.contentType);
|
|
49
|
+
return url;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if ('display_name' in form) cfg.name = String(form.display_name).trim() || cfg.handle;
|
|
53
|
+
if ('note' in form) cfg.summary = String(form.note) || undefined;
|
|
54
|
+
if ('locked' in form) cfg.approveJoins = form.locked === 'true' || form.locked === true;
|
|
55
|
+
if (files.avatar?.data?.length) cfg.icon = await putImage(files.avatar);
|
|
56
|
+
if (files.header?.data?.length) cfg.image = await putImage(files.header);
|
|
57
|
+
|
|
58
|
+
// fields_attributes arrives as fields_attributes[0][name] etc. A row with
|
|
59
|
+
// no name is how the editor says "delete this one", so it is dropped.
|
|
60
|
+
const rows = [];
|
|
61
|
+
for (const [k, v] of Object.entries(form)) {
|
|
62
|
+
const m = /^fields_attributes\[(\d+)\]\[(name|value)\]$/.exec(k);
|
|
63
|
+
if (!m) continue;
|
|
64
|
+
(rows[Number(m[1])] ||= {})[m[2]] = String(v);
|
|
65
|
+
}
|
|
66
|
+
if (rows.length) cfg.fields = rows.filter(r => r && r.name?.trim())
|
|
67
|
+
.map(r => ({ name: r.name.trim(), value: (r.value || '').trim() }));
|
|
68
|
+
|
|
69
|
+
api.store.setConfig(cfg);
|
|
70
|
+
Object.assign(api.agent.publisher.config, {
|
|
71
|
+
name: cfg.name, summary: cfg.summary, icon: cfg.icon, image: cfg.image,
|
|
72
|
+
fields: cfg.fields, approveJoins: !!cfg.approveJoins,
|
|
73
|
+
});
|
|
74
|
+
await api.store.flush();
|
|
75
|
+
// publishProfile says whether the world can actually read the actor it
|
|
76
|
+
// just wrote. Discarding that reported success for a save that left the
|
|
77
|
+
// account undiscoverable — the one outcome the caller needed to hear.
|
|
78
|
+
const published = await api.agent.publisher.publishProfile();
|
|
79
|
+
const unreachable = published?.unreachable;
|
|
80
|
+
if (unreachable?.length) {
|
|
81
|
+
api.log(`profile saved but NOT publicly readable: ${unreachable.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
api.log(`profile updated from a client: ${Object.keys(form).join(', ') || '(files only)'}`);
|
|
84
|
+
return send(200, api.selfAccount());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Follow requests. The queue, and the two answers to it, have existed since
|
|
88
|
+
// groups did — `agent.store.getRequests()`, `admitRequest`, `refuseRequest`,
|
|
89
|
+
// all driven from the record page — but the facade stubbed the list to `[]`
|
|
90
|
+
// and offered no authorize/reject. So a locked account could see and answer
|
|
91
|
+
// its requests in FediPod's own page and in NO Mastodon client: Phanpy,
|
|
92
|
+
// Tuba and Whalebird all showed nothing waiting.
|
|
93
|
+
if (pathname === '/api/v1/follow_requests' && req.method === 'GET') {
|
|
94
|
+
const limit = Math.min(Number(url.searchParams.get('limit')) || 40, 80);
|
|
95
|
+
return send(200, api.store.getRequests().slice(0, limit)
|
|
96
|
+
.map((r) => api.account(r.actor)));
|
|
97
|
+
}
|
|
98
|
+
const mReq = /^\/api\/v1\/follow_requests\/([a-f0-9]+)\/(authorize|reject)$/.exec(pathname);
|
|
99
|
+
if (mReq && req.method === 'POST') {
|
|
100
|
+
// The client addresses an account by the id it was given for it, which is
|
|
101
|
+
// this store's own hash of the actor URL — the same one every other
|
|
102
|
+
// account route here uses.
|
|
103
|
+
const actorUrl = api.store.urlFor(mReq[1]);
|
|
104
|
+
if (!actorUrl) return send(404, { error: 'Record not found' });
|
|
105
|
+
if (!api.store.getRequests().some((r) => r.actor === actorUrl)) {
|
|
106
|
+
return send(404, { error: 'Record not found' });
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
if (mReq[2] === 'authorize') await social.admitRequest(api.agent, actorUrl);
|
|
110
|
+
else await social.refuseRequest(api.agent, actorUrl);
|
|
111
|
+
} catch (e) { return send(422, { error: e.message }); }
|
|
112
|
+
await api.store.flush();
|
|
113
|
+
return send(200, api.relationship(actorUrl));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (pathname === '/api/v1/markers') {
|
|
117
|
+
if (req.method === 'POST') {
|
|
118
|
+
const body = await readBody(req);
|
|
119
|
+
const markers = api.store.read('masto-markers.json', {});
|
|
120
|
+
for (const [k, v] of Object.entries(body)) {
|
|
121
|
+
const lastId = v?.last_read_id || v;
|
|
122
|
+
if (typeof lastId === 'string') {
|
|
123
|
+
markers[k] = { last_read_id: lastId, version: (markers[k]?.version || 0) + 1, updated_at: new Date().toISOString() };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
api.store.write('masto-markers.json', markers);
|
|
127
|
+
return send(200, markers);
|
|
128
|
+
}
|
|
129
|
+
return send(200, api.store.read('masto-markers.json', {}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (pathname === '/api/v1/accounts/relationships') {
|
|
133
|
+
const ids = [...url.searchParams.getAll('id[]'), ...url.searchParams.getAll('id')];
|
|
134
|
+
const rels = ids.map(id => api.store.urlFor(id)).filter(Boolean).map(u => api.relationship(u));
|
|
135
|
+
return send(200, rels);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (pathname === '/api/v1/accounts/search') {
|
|
139
|
+
return send(200, await api.accountSearch(url.searchParams.get('q')));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (pathname === '/api/v1/accounts/lookup') {
|
|
143
|
+
const acct = String(url.searchParams.get('acct') || '').replace(/^@/, '');
|
|
144
|
+
const cfg = api.store.getConfig();
|
|
145
|
+
if (acct === cfg?.handle || acct === `${cfg?.handle}@${api.host}`) {
|
|
146
|
+
return send(200, api.selfAccount());
|
|
147
|
+
}
|
|
148
|
+
const hit = Object.entries(api.store.getActors()).find(([u, a]) => {
|
|
149
|
+
try { return `${a.preferredUsername}@${new URL(u).host}` === acct; } catch { return false; }
|
|
150
|
+
});
|
|
151
|
+
return hit ? send(200, api.account(hit[0])) : send(404, { error: 'Record not found' });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Block and mute, from where the trouble is seen. A block also unfollows —
|
|
155
|
+
// intake refuses a blocked author already — and a mute is view-only: their
|
|
156
|
+
// posts stay out of the timelines, nothing federates.
|
|
157
|
+
const mRel = /^\/api\/v1\/accounts\/([a-f0-9]+)\/(block|unblock|mute|unmute)$/.exec(pathname);
|
|
158
|
+
if (mRel && req.method === 'POST') {
|
|
159
|
+
const actorUrl = api.store.urlFor(mRel[1]);
|
|
160
|
+
if (!actorUrl) return send(404, { error: 'Record not found' });
|
|
161
|
+
if (mRel[2] === 'block' || mRel[2] === 'unblock') {
|
|
162
|
+
if (mRel[2] === 'block') await social.blockActor(api.agent, actorUrl);
|
|
163
|
+
else await social.unblockActor(api.agent, actorUrl);
|
|
164
|
+
} else {
|
|
165
|
+
const m = api.store.getMuted();
|
|
166
|
+
if (mRel[2] === 'mute' && !m.actors.includes(actorUrl)) m.actors.push(actorUrl);
|
|
167
|
+
if (mRel[2] === 'unmute') m.actors = m.actors.filter(a => a !== actorUrl);
|
|
168
|
+
api.store.setMuted(m);
|
|
169
|
+
}
|
|
170
|
+
return send(200, api.relationship(actorUrl));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const mFollow = /^\/api\/v1\/accounts\/([a-f0-9]+)\/(follow|unfollow)$/.exec(pathname);
|
|
174
|
+
if (mFollow && req.method === 'POST') {
|
|
175
|
+
const actorUrl = api.store.urlFor(mFollow[1]);
|
|
176
|
+
if (!actorUrl) return send(404, { error: 'Record not found' });
|
|
177
|
+
if (mFollow[2] === 'follow') await social.followActor(api.agent, actorUrl);
|
|
178
|
+
else await social.unfollowActor(api.agent, actorUrl).catch(() => {}); // already-gone is fine
|
|
179
|
+
return send(200, api.relationship(actorUrl));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const mAccount = /^\/api\/v1\/accounts\/([a-f0-9]+)$/.exec(pathname);
|
|
183
|
+
if (mAccount && req.method === 'GET') {
|
|
184
|
+
const actorUrl = api.store.urlFor(mAccount[1]);
|
|
185
|
+
return actorUrl ? send(200, api.account(actorUrl)) : send(404, { error: 'Record not found' });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Web push: one subscription per client login. The agent pushes payloads
|
|
189
|
+
// to the browser's push service itself, so a closed client still hears.
|
|
190
|
+
if (pathname === '/api/v1/push/subscription') {
|
|
191
|
+
const token = (/^Bearer (.+)$/.exec(req.headers.authorization || '') || [])[1];
|
|
192
|
+
if (!token) return send(401, { error: 'The access token is invalid' });
|
|
193
|
+
// A client that ignores the missing `vapid` and subscribes anyway must
|
|
194
|
+
// not be told it worked — a stored subscription nothing ever pushes to is
|
|
195
|
+
// the same silent nothing the toggle was.
|
|
196
|
+
if (!api.webPush) return send(422, { error: 'this instance does not send web push' });
|
|
197
|
+
if (req.method === 'GET') {
|
|
198
|
+
const sub = api.push.get(token);
|
|
199
|
+
return sub ? send(200, api.push.json(token, sub)) : send(404, { error: 'Record not found' });
|
|
200
|
+
}
|
|
201
|
+
if (req.method === 'POST') {
|
|
202
|
+
const body = await readBody(req);
|
|
203
|
+
const sub = api.push.set(token, {
|
|
204
|
+
endpoint: body.subscription?.endpoint,
|
|
205
|
+
keys: body.subscription?.keys,
|
|
206
|
+
alerts: body.data?.alerts,
|
|
207
|
+
});
|
|
208
|
+
if (!sub) return send(422, { error: 'a https endpoint and p256dh/auth keys are required' });
|
|
209
|
+
return send(200, api.push.json(token, sub));
|
|
210
|
+
}
|
|
211
|
+
if (req.method === 'PUT') {
|
|
212
|
+
const body = await readBody(req);
|
|
213
|
+
const sub = api.push.setAlerts(token, body.data?.alerts);
|
|
214
|
+
return sub ? send(200, api.push.json(token, sub)) : send(404, { error: 'Record not found' });
|
|
215
|
+
}
|
|
216
|
+
if (req.method === 'DELETE') {
|
|
217
|
+
api.push.drop(token);
|
|
218
|
+
return send(200, {});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (/^\/api\/v1\/accounts\/[a-f0-9]+\/featured_tags$/.test(pathname)) return send(200, []);
|
|
223
|
+
|
|
224
|
+
// The counts these back are rendered from the same two arrays (see
|
|
225
|
+
// `account`), so a client that shows a number here can always open it.
|
|
226
|
+
const mAccList = /^\/api\/v1\/accounts\/([a-f0-9]+)\/(following|followers)$/.exec(pathname);
|
|
227
|
+
if (mAccList && req.method === 'GET') {
|
|
228
|
+
const actorUrl = api.store.urlFor(mAccList[1]);
|
|
229
|
+
if (!actorUrl) return send(404, { error: 'Record not found' });
|
|
230
|
+
// Only our own lists are known. A remote actor's collections live on its
|
|
231
|
+
// own server, and opening a profile is not worth a fetch of a stranger's
|
|
232
|
+
// pod — an empty list, not an error, is what the API can honestly say.
|
|
233
|
+
const mine = actorUrl === api.urls?.actor;
|
|
234
|
+
const c = api.store.getContacts();
|
|
235
|
+
// Reversed: the contact arrays append, and page()'s cursors read
|
|
236
|
+
// newest-first — fed as stored, since_id answered with its complement.
|
|
237
|
+
const recs = (!mine ? []
|
|
238
|
+
: mAccList[2] === 'followers' ? c.followers
|
|
239
|
+
: c.following.filter(f => f.accepted)) // pending is not following
|
|
240
|
+
.slice().reverse();
|
|
241
|
+
const { items, headers } = api.page(recs, url,
|
|
242
|
+
{ limit: 40, max: 80, idOf: (r) => api.store.idFor(r.actor) });
|
|
243
|
+
return send(200, items.map(r => api.account(r.actor)), headers);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Who among the people I follow also follows THEM — a graph we do not hold,
|
|
247
|
+
// and the shape is an entry per requested account, not a bare list.
|
|
248
|
+
if (pathname === '/api/v1/accounts/familiar_followers') {
|
|
249
|
+
const ids = [...url.searchParams.getAll('id[]'), ...url.searchParams.getAll('id')];
|
|
250
|
+
return send(200, ids.map(id => ({ id, accounts: [] })));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const mAccStatuses = /^\/api\/v1\/accounts\/([a-f0-9]+)\/statuses$/.exec(pathname);
|
|
254
|
+
if (mAccStatuses) {
|
|
255
|
+
const actorUrl = api.store.urlFor(mAccStatuses[1]);
|
|
256
|
+
const all = api.store.getStatuses();
|
|
257
|
+
const pinnedOnly = url.searchParams.get('pinned') === 'true';
|
|
258
|
+
const { items, headers } = api.page(
|
|
259
|
+
all.filter(s => s.actor === actorUrl && (!pinnedOnly || s.pinned)), url);
|
|
260
|
+
return send(200, items.map(s => api.statusOrBoost(s, { all })), headers);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// body.mjs — reading what a client sent: JSON or form-encoded bodies, the
|
|
2
|
+
// poll a compose request carries, and the text of a post back out of its
|
|
3
|
+
// HTML.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A poll out of a compose request, or null when there is none. A JSON client
|
|
9
|
+
* sends a `poll` object; a form-encoded one spells the same thing out in
|
|
10
|
+
* Rails's bracket notation, which is the shape the option list arrives in.
|
|
11
|
+
*/
|
|
12
|
+
export function pollParams(body) {
|
|
13
|
+
const nested = body?.poll && typeof body.poll === 'object' ? body.poll : null;
|
|
14
|
+
const options = [].concat(nested?.options ?? body?.['poll[options][]'] ?? [])
|
|
15
|
+
.map(o => String(o ?? '').trim()).filter(Boolean);
|
|
16
|
+
const rawExpiry = nested?.expires_in ?? body?.['poll[expires_in]'];
|
|
17
|
+
const rawMultiple = nested?.multiple ?? body?.['poll[multiple]'];
|
|
18
|
+
if (!options.length && rawExpiry === undefined) return null;
|
|
19
|
+
return {
|
|
20
|
+
options,
|
|
21
|
+
expiresIn: rawExpiry === undefined || rawExpiry === null || rawExpiry === ''
|
|
22
|
+
? null : Number(rawExpiry),
|
|
23
|
+
multiple: rawMultiple === true || rawMultiple === 'true' || rawMultiple === '1',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Accepts JSON or form-encoded bodies (OAuth posts are often form-encoded).
|
|
28
|
+
// The source of a post that predates raw-text storage: its HTML back to
|
|
29
|
+
// typed text, near enough to edit.
|
|
30
|
+
export function htmlToText(html) {
|
|
31
|
+
return String(html)
|
|
32
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
33
|
+
.replace(/<\/p>\s*<p[^>]*>/gi, '\n\n')
|
|
34
|
+
.replace(/<[^>]+>/g, '')
|
|
35
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
36
|
+
.replace(/"/g, '"').replace(/'/g, "'")
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readBody(req) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let data = '';
|
|
43
|
+
req.on('data', c => {
|
|
44
|
+
data += c;
|
|
45
|
+
// Destroying the socket without settling left the awaiting handler
|
|
46
|
+
// pending for the life of the process and the client waiting on a
|
|
47
|
+
// response that would never come.
|
|
48
|
+
if (data.length > 1e6) { req.destroy(); reject(new Error('request body too large')); }
|
|
49
|
+
});
|
|
50
|
+
req.on('end', () => {
|
|
51
|
+
const ct = String(req.headers['content-type'] || '');
|
|
52
|
+
try {
|
|
53
|
+
if (ct.includes('application/json')) return resolve(data ? JSON.parse(data) : {});
|
|
54
|
+
// A form-encoded list is the same key repeated, spelled with a
|
|
55
|
+
// trailing `[]`. Reading it as a plain object kept only the last one,
|
|
56
|
+
// so a client sending its poll or its media that way lost all but the
|
|
57
|
+
// final value. Only the `[]` keys become lists: everything else keeps
|
|
58
|
+
// the single value the rest of this file reads.
|
|
59
|
+
const form = new URLSearchParams(data);
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const key of new Set(form.keys())) {
|
|
62
|
+
out[key] = key.endsWith('[]') ? form.getAll(key) : form.get(key);
|
|
63
|
+
}
|
|
64
|
+
resolve(out);
|
|
65
|
+
} catch (e) { reject(e); }
|
|
66
|
+
});
|
|
67
|
+
req.on('error', reject);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// mastoapi.mjs — Mastodon client-API facade over ap-agent (M1: read + post).
|
|
2
|
+
// Modeled on snac2's approach: real implementations for the endpoints
|
|
3
|
+
// clients actually exercise, empty-collection stubs for the rest. Reached
|
|
4
|
+
// through the router (/api/*, /oauth/* → this port), so requests carry the
|
|
5
|
+
// gate; OAuth here is theater for a single already-trusted local user.
|
|
6
|
+
//
|
|
7
|
+
// Surface: oauth trio · instance v1/v2 · verify_credentials · timelines/home
|
|
8
|
+
// (M1) · notifications, relationships, lookup, follow/unfollow, thread
|
|
9
|
+
// context, /v2/search (M2) · favourite/reblog, media upload, markers,
|
|
10
|
+
// DELETE status (M3) · stub farm. Unknown /api/* GETs 404 and are LOGGED —
|
|
11
|
+
// that log is the running punch list.
|
|
12
|
+
//
|
|
13
|
+
// This file is the class and the dispatcher. The endpoints are in the modules
|
|
14
|
+
// beside it, one per area — oauth.mjs, instance.mjs, accounts.mjs,
|
|
15
|
+
// timelines.mjs, statuses.mjs, media.mjs — each exporting handle(api, ctx),
|
|
16
|
+
// which answers and returns true or returns false; handle() below walks them
|
|
17
|
+
// in order with the bearer gate between the public ones and the rest. The
|
|
18
|
+
// JSON shapes are render.mjs and the request readers body.mjs; every helper
|
|
19
|
+
// the areas reach through `api.` is a one-line delegation here.
|
|
20
|
+
|
|
21
|
+
import { Push } from '../webpush.mjs';
|
|
22
|
+
import * as oauth from './oauth.mjs';
|
|
23
|
+
import * as render from './render.mjs';
|
|
24
|
+
import * as instance from './instance.mjs';
|
|
25
|
+
import * as accounts from './accounts.mjs';
|
|
26
|
+
import * as timelines from './timelines.mjs';
|
|
27
|
+
import * as statuses from './statuses.mjs';
|
|
28
|
+
import * as media from './media.mjs';
|
|
29
|
+
export { hashPassword } from './oauth.mjs';
|
|
30
|
+
export { pollParams } from './body.mjs';
|
|
31
|
+
export { attachmentType, extensionFor } from './media.mjs';
|
|
32
|
+
|
|
33
|
+
export class MastoApi {
|
|
34
|
+
constructor({ agent, log = console.log, allowed = null, scheme = null, embedded = false,
|
|
35
|
+
streaming = true, webPush = true, scheduling = true }) {
|
|
36
|
+
this.agent = agent;
|
|
37
|
+
// A server-hosted identity has no CLI of its own, so the advice this gives
|
|
38
|
+
// when it refuses has to name the route that identity really has.
|
|
39
|
+
this.embedded = embedded;
|
|
40
|
+
this.log = log;
|
|
41
|
+
this.allowed = allowed; // authorities a redirect_uri may name
|
|
42
|
+
// The scheme this identity is reached on, when the socket cannot say —
|
|
43
|
+
// a server behind a TLS proxy terminates cleartext and still is https.
|
|
44
|
+
this.scheme = scheme;
|
|
45
|
+
// Whether this deployment can serve the Mastodon streaming WebSocket. A
|
|
46
|
+
// Node agent can; the in-browser service-worker facade cannot (it is
|
|
47
|
+
// fetch-only), so it advertises NO streaming URL. Otherwise the client
|
|
48
|
+
// opens wss://localhost/... (the worker has no request host to name), which
|
|
49
|
+
// not only fails but, from a public origin, trips Chrome's private-network
|
|
50
|
+
// permission prompt ("access other apps and services on this device").
|
|
51
|
+
// With no streaming URL the client falls back to polling, which is served.
|
|
52
|
+
this.streaming = streaming;
|
|
53
|
+
// The same idea for two more capabilities the browser build does not have.
|
|
54
|
+
// A client decides what to offer from what the instance document says, so
|
|
55
|
+
// the honest thing is to say nothing rather than advertise and no-op.
|
|
56
|
+
//
|
|
57
|
+
// `webPush`: the browser build's web-push is a shim that mints a random
|
|
58
|
+
// placeholder VAPID key and whose sendNotification does nothing. Advertised,
|
|
59
|
+
// the client shows a notifications toggle that looks on and never fires.
|
|
60
|
+
// Omit `vapid` and it hides the toggle instead.
|
|
61
|
+
this.webPush = webPush;
|
|
62
|
+
// `scheduling`: a scheduled post is only a stored row until something
|
|
63
|
+
// publishes it when its time comes, and the only such tick is the Node
|
|
64
|
+
// agent's (run-agent.mjs). Where nothing ticks, accepting one is silent
|
|
65
|
+
// loss — the client says "scheduled" and the post never appears. Refusing
|
|
66
|
+
// it is worse UX and better behaviour.
|
|
67
|
+
this.scheduling = scheduling;
|
|
68
|
+
this.authzAttempts = []; // password-attempt timestamps
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get store() { return this.agent.store; }
|
|
72
|
+
get urls() { return this.agent.publisher?.urls; }
|
|
73
|
+
get push() {
|
|
74
|
+
this._push ||= new Push({
|
|
75
|
+
store: this.store,
|
|
76
|
+
subject: () => this.urls?.actor || 'https://localhost/',
|
|
77
|
+
log: this.log,
|
|
78
|
+
});
|
|
79
|
+
return this._push;
|
|
80
|
+
}
|
|
81
|
+
get host() { return this.urls ? new URL(this.urls.base).host : 'unconfigured.invalid'; }
|
|
82
|
+
|
|
83
|
+
// Where the live feed is, as the CLIENT must address it: this agent's own
|
|
84
|
+
// origin, taken from the request, not the pod's host. An instance document
|
|
85
|
+
// that leaves it empty is not merely unhelpful — clients read it without a
|
|
86
|
+
// guard and fall over, and every one of them loses live updates.
|
|
87
|
+
streamingUrl(req) {
|
|
88
|
+
if (!this.streaming) return null; // fetch-only facade: no WebSocket, no wss://localhost prompt
|
|
89
|
+
const host = req?.headers?.host || `localhost:${this.port || ''}`;
|
|
90
|
+
const secure = this.scheme
|
|
91
|
+
? this.scheme.startsWith('https')
|
|
92
|
+
: req?.headers?.['x-forwarded-proto'] === 'https' || !!req?.socket?.encrypted;
|
|
93
|
+
return `${secure ? 'wss' : 'ws'}://${host}/api/v1/streaming`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static scopeFor(...a) { return oauth.scopeFor(...a); }
|
|
97
|
+
static scopeAllows(...a) { return oauth.scopeAllows(...a); }
|
|
98
|
+
static redirectMatches(...a) { return oauth.redirectMatches(...a); }
|
|
99
|
+
static provesCode(...a) { return oauth.provesCode(...a); }
|
|
100
|
+
|
|
101
|
+
// ---- request handling; returns true when handled ----
|
|
102
|
+
async handle(req, res, pathname, url) {
|
|
103
|
+
const send = (status, obj, headers = {}) => {
|
|
104
|
+
const body = JSON.stringify(obj);
|
|
105
|
+
res.writeHead(status, { 'content-type': 'application/json', ...headers });
|
|
106
|
+
res.end(body);
|
|
107
|
+
return true;
|
|
108
|
+
};
|
|
109
|
+
const ctx = { req, res, pathname, url, send };
|
|
110
|
+
|
|
111
|
+
if (await oauth.handle(this, ctx)) return true;
|
|
112
|
+
if (!pathname.startsWith('/api/')) return false;
|
|
113
|
+
if (await instance.handle(this, ctx)) return true;
|
|
114
|
+
|
|
115
|
+
// --- everything below needs a bearer token + a configured agent ---
|
|
116
|
+
const bearer = this.tokenOf(req);
|
|
117
|
+
if (!bearer) return send(401, { error: 'The access token is invalid' });
|
|
118
|
+
// One gate for every client route below, rather than a check in each of
|
|
119
|
+
// the fifty-odd branches — which is how the scope came to be recorded and
|
|
120
|
+
// never consulted in the first place.
|
|
121
|
+
const need = MastoApi.scopeFor(req.method, pathname);
|
|
122
|
+
if (!MastoApi.scopeAllows(bearer.scope, need)) {
|
|
123
|
+
this.log(`refused ${req.method} ${pathname}: token has "${bearer.scope}", needs "${need}"`);
|
|
124
|
+
return send(403, { error: `This action is outside the authorized scopes (needs ${need})` });
|
|
125
|
+
}
|
|
126
|
+
if (!this.agent.configured()) return send(503, { error: 'agent not configured' });
|
|
127
|
+
// A viewer-mode agent (another agent holds the drain lease) may not act —
|
|
128
|
+
// but a user acting HERE outranks the idle active agent elsewhere, so a
|
|
129
|
+
// write attempt claims the lease and proceeds. Only a failed claim 503s.
|
|
130
|
+
if (this.agent.viewer && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
131
|
+
const took = await this.agent.requestTakeover?.();
|
|
132
|
+
if (!took) return send(503, { error: 'another agent is active for this pod — takeover failed, try again' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const area of [accounts, timelines, statuses, media]) {
|
|
136
|
+
if (await area.handle(this, ctx)) return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.log(`mastoapi: unhandled ${req.method} ${pathname} — punch list`);
|
|
140
|
+
return send(404, { error: `Unimplemented: ${req.method} ${pathname}` });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// oauth.mjs
|
|
144
|
+
tokenRecords(...a) { return oauth.tokenRecords(this, ...a); }
|
|
145
|
+
tokens(...a) { return oauth.tokens(this, ...a); }
|
|
146
|
+
mintToken(...a) { return oauth.mintToken(this, ...a); }
|
|
147
|
+
apps(...a) { return oauth.apps(this, ...a); }
|
|
148
|
+
authorizationServerMetadata(...a) { return oauth.authorizationServerMetadata(this, ...a); }
|
|
149
|
+
findApp(...a) { return oauth.findApp(this, ...a); }
|
|
150
|
+
resolveClientDocument(...a) { return oauth.resolveClientDocument(this, ...a); }
|
|
151
|
+
registerApp(...a) { return oauth.registerApp(this, ...a); }
|
|
152
|
+
mintCode(...a) { return oauth.mintCode(this, ...a); }
|
|
153
|
+
consumeCode(...a) { return oauth.consumeCode(this, ...a); }
|
|
154
|
+
tokenOf(...a) { return oauth.tokenOf(this, ...a); }
|
|
155
|
+
authed(...a) { return oauth.authed(this, ...a); }
|
|
156
|
+
redirectAllowed(...a) { return oauth.redirectAllowed(this, ...a); }
|
|
157
|
+
rateLimited(...a) { return oauth.rateLimited(this, ...a); }
|
|
158
|
+
|
|
159
|
+
// render.mjs
|
|
160
|
+
selfAccount(...a) { return render.selfAccount(this, ...a); }
|
|
161
|
+
account(...a) { return render.account(this, ...a); }
|
|
162
|
+
page(...a) { return render.page(this, ...a); }
|
|
163
|
+
bskyReply(...a) { return render.bskyReply(this, ...a); }
|
|
164
|
+
acctAction(...a) { return render.acctAction(this, ...a); }
|
|
165
|
+
acctReply(...a) { return render.acctReply(this, ...a); }
|
|
166
|
+
status(...a) { return render.status(this, ...a); }
|
|
167
|
+
filtersFor(...a) { return render.filtersFor(this, ...a); }
|
|
168
|
+
lookup(...a) { return render.lookup(this, ...a); }
|
|
169
|
+
statusOrBoost(...a) { return render.statusOrBoost(this, ...a); }
|
|
170
|
+
pushNotify(...a) { return render.pushNotify(this, ...a); }
|
|
171
|
+
scheduledJson(...a) { return render.scheduledJson(this, ...a); }
|
|
172
|
+
pollJson(...a) { return render.pollJson(this, ...a); }
|
|
173
|
+
mediaJson(...a) { return render.mediaJson(this, ...a); }
|
|
174
|
+
relationship(...a) { return render.relationship(this, ...a); }
|
|
175
|
+
notificationType(...a) { return render.notificationType(this, ...a); }
|
|
176
|
+
notification(...a) { return render.notification(this, ...a); }
|
|
177
|
+
accountSearch(...a) { return render.accountSearch(this, ...a); }
|
|
178
|
+
|
|
179
|
+
// instance.mjs
|
|
180
|
+
instanceTitle(...a) { return instance.instanceTitle(this, ...a); }
|
|
181
|
+
tagObject(...a) { return instance.tagObject(this, ...a); }
|
|
182
|
+
instanceBlurb(...a) { return instance.instanceBlurb(this, ...a); }
|
|
183
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// instance.mjs — what the instance says about itself: the v1 and v2 instance
|
|
2
|
+
// documents, the limits a client reads from them, the title and blurb, the
|
|
3
|
+
// tag object, and the empty-collection stubs for endpoints a single-actor
|
|
4
|
+
// instance has nothing to say to.
|
|
5
|
+
|
|
6
|
+
import { TRANSPARENT_PNG } from './render.mjs';
|
|
7
|
+
import {
|
|
8
|
+
MAX_OPTIONS as POLL_MAX_OPTIONS, MAX_OPTION_CHARS as POLL_MAX_OPTION_CHARS,
|
|
9
|
+
MIN_SECONDS as POLL_MIN_SECONDS, MAX_SECONDS as POLL_MAX_SECONDS,
|
|
10
|
+
} from '../../core/polls.mjs';
|
|
11
|
+
|
|
12
|
+
const STUBS = new Map(Object.entries({
|
|
13
|
+
'/api/v1/filters': [],
|
|
14
|
+
'/api/v1/custom_emojis': [],
|
|
15
|
+
'/api/v1/announcements': [],
|
|
16
|
+
'/api/v1/instance/peers': [],
|
|
17
|
+
'/api/v1/trends/tags': [], '/api/v1/trends/links': [],
|
|
18
|
+
'/api/v2/suggestions': [],
|
|
19
|
+
'/api/v1/preferences': {},
|
|
20
|
+
})); // followed_tags is served live from the tag feed, not stubbed
|
|
21
|
+
|
|
22
|
+
export function instanceConfig() {
|
|
23
|
+
return {
|
|
24
|
+
statuses: { max_characters: 5000, max_media_attachments: 4, characters_reserved_per_url: 23 },
|
|
25
|
+
media_attachments: {
|
|
26
|
+
supported_mime_types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
|
27
|
+
'video/mp4', 'video/webm', 'audio/mpeg', 'audio/ogg'],
|
|
28
|
+
image_size_limit: 10 * 1024 * 1024, video_size_limit: 40 * 1024 * 1024,
|
|
29
|
+
image_matrix_limit: 16777216, video_matrix_limit: 2304000,
|
|
30
|
+
},
|
|
31
|
+
polls: {
|
|
32
|
+
max_options: POLL_MAX_OPTIONS,
|
|
33
|
+
max_characters_per_option: POLL_MAX_OPTION_CHARS,
|
|
34
|
+
min_expiration: POLL_MIN_SECONDS,
|
|
35
|
+
max_expiration: POLL_MAX_SECONDS,
|
|
36
|
+
},
|
|
37
|
+
accounts: { max_featured_tags: 0 },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Every agent used to report title 'solid-activitypub', so a client holding two
|
|
42
|
+
// of them showed two identical instances and you had to read the acct to tell
|
|
43
|
+
// them apart. The title is free text no client parses — make it say who.
|
|
44
|
+
export function instanceTitle(api) {
|
|
45
|
+
const cfg = api.store.getConfig();
|
|
46
|
+
return cfg?.handle ? `@${cfg.handle}@${api.host}` : 'FediPod';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A Mastodon Tag object. The client reads `following` in its Followed
|
|
50
|
+
// Hashtags view and toggles it with the follow/unfollow endpoints. No usage
|
|
51
|
+
// history — a single-actor instance has no firehose stats to report.
|
|
52
|
+
export function tagObject(api, name, following, req) {
|
|
53
|
+
const host = req?.headers?.host || api.host;
|
|
54
|
+
return { name, url: `https://${host}/tags/${name}`, history: [], following: !!following };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function instanceBlurb(api) {
|
|
58
|
+
const kind = api.store.getConfig()?.kind === 'group' ? 'group' : 'actor';
|
|
59
|
+
return `Solid pod ActivityPub ${kind}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function handle(api, ctx) {
|
|
63
|
+
const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
|
|
64
|
+
|
|
65
|
+
// --- instance (public) ---
|
|
66
|
+
if (pathname === '/api/v1/instance') {
|
|
67
|
+
const su = api.streamingUrl(req);
|
|
68
|
+
return send(200, {
|
|
69
|
+
uri: api.host, title: api.instanceTitle(), short_description: api.instanceBlurb(),
|
|
70
|
+
description: api.instanceBlurb(), email: '', version: '4.2.0 (compatible; fedipod)',
|
|
71
|
+
urls: su ? { streaming_api: su } : {},
|
|
72
|
+
stats: { user_count: 1, status_count: api.store.countStatuses(), domain_count: 1 },
|
|
73
|
+
languages: ['en'], registrations: false, approval_required: false, invites_enabled: false,
|
|
74
|
+
configuration: instanceConfig(),
|
|
75
|
+
contact_account: null, rules: [],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (pathname === '/api/v2/instance') {
|
|
79
|
+
const su = api.streamingUrl(req);
|
|
80
|
+
return send(200, {
|
|
81
|
+
domain: api.host, title: api.instanceTitle(), version: '4.2.0 (compatible; fedipod)',
|
|
82
|
+
source_url: 'https://github.com/jeff-zucker/FediPod', description: api.instanceBlurb(),
|
|
83
|
+
usage: { users: { active_month: 1 } },
|
|
84
|
+
thumbnail: { url: TRANSPARENT_PNG },
|
|
85
|
+
languages: ['en'],
|
|
86
|
+
// Both spellings: v2 clients read configuration.urls.streaming, older
|
|
87
|
+
// ones the top-level urls.streaming_api, and some fall back blindly.
|
|
88
|
+
// Omitted entirely when there is no streaming, so the client polls.
|
|
89
|
+
urls: su ? { streaming_api: su } : {},
|
|
90
|
+
configuration: {
|
|
91
|
+
...instanceConfig(),
|
|
92
|
+
...(su ? { urls: { streaming: su } } : {}),
|
|
93
|
+
...(api.webPush ? { vapid: { public_key: api.push.publicKey() } } : {}),
|
|
94
|
+
},
|
|
95
|
+
registrations: { enabled: false, approval_required: false, message: null },
|
|
96
|
+
contact: { email: '', account: null }, rules: [],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stub = STUBS.get(pathname);
|
|
101
|
+
if (stub !== undefined && req.method === 'GET') return send(200, stub);
|
|
102
|
+
|
|
103
|
+
return false;
|
|
104
|
+
}
|