server: ActivityStreams reply/link/unlink API
s-ol
1 year, 7 months ago
22 | 22 | app.use(express.json()); |
23 | 23 | app.use(cookieParser()); |
24 | 24 | |
25 | type LinkOr<T> = string | T; | |
26 | ||
27 | type Person = { | |
28 | id: string; | |
29 | type: 'Person', | |
30 | name: string; | |
31 | }; | |
32 | ||
25 | 33 | type Note = { |
26 | 34 | type: 'Note'; |
27 | 35 | published: string; |
28 | attributedTo: string | { | |
29 | id: string; | |
30 | type: 'Person', | |
31 | name: string; | |
32 | }; | |
36 | attributedTo: LinkOr<Person>; | |
33 | 37 | inReplyTo: string[]; |
34 | 38 | replies: string[]; |
35 | 39 | content: string; |
40 | }; | |
41 | ||
42 | type Relationship<S, O> = { | |
43 | type: 'Relationship'; | |
44 | relationship: string; | |
45 | subject: S; | |
46 | object: O; | |
47 | }; | |
48 | ||
49 | type Create<O> = { | |
50 | type: 'Create'; | |
51 | actor: LinkOr<Person>; | |
52 | object: O; | |
53 | }; | |
54 | ||
55 | type Delete<O> = { | |
56 | type: 'Create'; | |
57 | actor: LinkOr<Person>; | |
58 | object: O; | |
59 | }; | |
60 | ||
61 | const iri2id = (iri: string) => { | |
62 | const parts = iri.split('/'); | |
63 | return parts[parts.length - 1]; | |
36 | 64 | }; |
37 | 65 | |
38 | 66 | const discdag = express() |
188 | 216 | { |
189 | 217 | items: { |
190 | 218 | '@id': 'as:items', |
191 | '@type': '@id', | |
192 | 219 | '@container': '@id' |
193 | 220 | }, |
194 | 221 | }, |
201 | 228 | }); |
202 | 229 | })) |
203 | 230 | |
204 | .put('/disc/:discussion', wrap(async ($req, $res) => { | |
205 | const { inReplyTo, content } = $req.body as { inReplyTo: string[], content: string }; | |
231 | .post('/disc/:discussion', wrap(async ($req, $res) => { | |
206 | 232 | const { discussion } = $req.params; |
207 | 233 | const { digest } = $req.cookies; |
208 | 234 | |
209 | 235 | if (!digest || !digest.length) |
210 | 236 | return $res.status(401).end(); |
211 | 237 | |
212 | if (!inReplyTo.length || !content.length) | |
213 | return $res.status(400).end(); | |
214 | ||
215 | const parents = inReplyTo.map(id => { | |
216 | const parts = id.split('/'); | |
217 | return parts[parts.length - 1]; | |
218 | }).join(','); | |
219 | ||
220 | const res = await fetch(DISCDAG_URL, { | |
221 | method: 'POST', | |
222 | headers: { | |
223 | 'Content-Type': 'application/x-www-form-urlencoded', | |
224 | 'Cookie': `DiscussionID=${discussion}; digest=${digest}; SelectedNodes=${parents}`, | |
225 | }, | |
226 | body: new URLSearchParams({ Action: 'SendReply', ReplyText: content }).toString(), | |
227 | }); | |
238 | type Activity = | |
239 | | Create<Note> | |
240 | | Create<Relationship<string, string>> | |
241 | | Delete<Relationship<string, string>> | |
242 | ; | |
243 | const activity = $req.body as Activity; | |
244 | ||
245 | let selection: string[]; | |
246 | let body: string; | |
247 | let method = 'GET'; | |
248 | if (activity.type === 'Create' && activity.object?.type === 'Note') { | |
249 | method = 'POST'; | |
250 | selection = activity.object.inReplyTo.map(iri2id); | |
251 | body = new URLSearchParams({ Action: 'SendReply', ReplyText: activity.object.content }).toString(); | |
252 | } else if (activity.object?.type === 'Relationship' && activity.object.relationship === 'as:inReplyTo') { | |
253 | const frm = iri2id(activity.object.object); | |
254 | const to = iri2id(activity.object.subject); | |
255 | selection = [frm, to]; | |
256 | body = new URLSearchParams({ | |
257 | Action: activity.type === 'Create' ? 'CreateLink' : 'RemoveLink', | |
258 | N0: frm, | |
259 | N1: to, | |
260 | }).toString(); | |
261 | } else { | |
262 | return $res.status(501).end(); | |
263 | } | |
264 | ||
265 | const headers = { | |
266 | 'Content-Type': 'application/x-www-form-urlencoded', | |
267 | 'Cookie': `DiscussionID=${discussion}; digest=${digest}; SelectedNodes=${selection.join(',')}`, | |
268 | }; | |
269 | ||
270 | let res; | |
271 | if (method === 'POST') { | |
272 | res = await fetch(DISCDAG_URL, { method, headers, body }); | |
273 | } else { | |
274 | res = await fetch(DISCDAG_URL + `?${body}`, { method, headers }); | |
275 | } | |
228 | 276 | |
229 | 277 | if (res.status !== 200) |
230 | 278 | throw new Error("bad response"); |
270 | 318 | items[to]?.inReplyTo.push(frm); |
271 | 319 | } |
272 | 320 | |
273 | return $res | |
274 | .json({ | |
275 | '@context': 'https://www.w3.org/ns/activitystreams', | |
276 | items, | |
277 | }); | |
321 | return $res.json({ | |
322 | '@context': [ | |
323 | 'https://www.w3.org/ns/activitystreams', | |
324 | { | |
325 | items: { | |
326 | '@id': 'as:items', | |
327 | '@container': '@id' | |
328 | }, | |
329 | }, | |
330 | ], | |
331 | items, | |
332 | }); | |
278 | 333 | })) |
279 | 334 | ; |
280 | 335 | |
317 | 372 | |
318 | 373 | try { |
319 | 374 | app.listen(PORT, (): void => { |
320 | console.log(`Connected successfully on port ${PORT}`); | |
375 | console.log(`Connected successfully on port ${PORT}`); | |
321 | 376 | }); |
322 | 377 | } catch (error) { |
323 | 378 | console.error(`Error occured: ${error}`); |