import { URL, URLSearchParams } from "url"; import express from "express"; import cors from "cors"; import fetch from "node-fetch"; import cookieParser from "cookie-parser"; import * as cheerio from "cheerio"; import { wrap } from "./async"; const PORT = process.env.PORT ? +process.env.PORT : 3000; const DISCDAG_URL = process.env.DISCDAG_URL ?? 'http://solipsys.co.uk/cgi-bin/DiscDAG.py'; const PUBLIC_URL = process.env.PUBLIC_URL ?? 'http://localhost:3000'; const DEBUG = process.env.NODE_ENV ? process.env.NODE_ENV !== 'production' : PUBLIC_URL.indexOf('localhost') > -1; const app = express(); if (DEBUG) { app.use(cors({ origin: true, credentials: true })); app.use(express.static('../client/dist/')); } else { app.use(express.static('static')); } app.use(express.json()); app.use(cookieParser()); type LinkOr = string | T; type Person = { id: string; type: 'Person', name: string; }; type Note = { type: 'Note'; published: string; attributedTo: LinkOr; inReplyTo: string[]; replies: string[]; content: string; }; type Relationship = { type: 'Relationship'; relationship: string; subject: S; object: O; }; type Create = { type: 'Create'; actor: LinkOr; object: O; }; type Delete = { type: 'Create'; actor: LinkOr; object: O; }; const iri2id = (iri: string) => { const parts = iri.split('/'); return parts[parts.length - 1]; }; const discdag = express() .post('/login', wrap(async ($req, $res) => { const { username, password } = $req.body; if (!username || !password) throw new Error("no credentials"); const res = await fetch(DISCDAG_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ Action: 'Login', Username: username, Password: password, }).toString(), }); if (res.status !== 200) throw new Error("bad response"); const cookiestr = res.headers.get('set-cookie') ?? ''; const match = cookiestr.match(/digest=([^ ]+);/); if (!match || !match[1].length) throw new Error("wrong credentials"); const doc = cheerio.load(await res.text()); const name = doc("font b").text().match(/browsing as (.*)$/)![1]; const user = `${PUBLIC_URL}/discdag/user/${name}`; return $res .cookie('digest', match[1]) .json({ '@context': 'https://www.w3.org/ns/activitystreams', id: user, type: 'User', name, }); })) .post('/logout', wrap(async ($req, $res) => $res.cookie('digest', '').json({}))) .get('/list', wrap(async ($req, $res) => { const { digest } = $req.cookies; const res = await fetch(DISCDAG_URL + '?Action=ListDiscussions', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': `digest=${digest}`, }, }); const doc = cheerio.load(await res.text()); const name = doc("font b").text().match(/browsing as (.*)$/)![1]; const user = `${PUBLIC_URL}/discdag/user/${name}`; const discussions = doc("ul > li").map(function(this: any) { const writeAccess = this.children[0].data.indexOf('(W)') > -1; const href = doc("a", this).attr("href")!; const id = href.match(/cgi-bin\/DiscDAG.py\?DiscussionID=(.+)/)![1]; return { id: `${PUBLIC_URL}/discdag/disc/${id}`, type: 'Document', name: id, url: { type: 'Link', href, }, attributedTo: writeAccess ? user : null, audience: user, }; }).toArray(); return $res.json({ discussions, user: { id: user, type: 'User', name, }, }); })) .get('/user/:name', wrap(async ($req, $res) => { const { name } = $req.params; return $res.json({ '@context': 'https://www.w3.org/ns/activitystreams', id: `${PUBLIC_URL}/discdag/user/${name}`, type: 'User', name, }); })) .get('/disc/:discussion', wrap(async ($req, $res) => { const { discussion } = $req.params; const { digest } = $req.cookies; const res = await fetch(DISCDAG_URL, { headers: { 'Cookie': `DiscussionID=${discussion}; digest=${digest}`, } }); if (res.status !== 200) throw new Error("bad response"); const doc = cheerio.load(await res.text()); if (doc.text().indexOf("Invalid discussion ID:") > -1) return $res.status(404).end(); let first = null; const items: Record = {}; for (const node of doc('g.node', 'svg')) { const nodeId = doc('title', node).text(); const [_, time, author] = nodeId.split('_'); const id = `${PUBLIC_URL}/discdag/disc/${discussion}/${nodeId}`; first = first ?? id; const content = doc('g:first text', node) .slice(3) .map(function(this: any) { return doc(this).text(); }) .toArray() .join(' ').trim(); items[id] = { type: 'Note', published: time.replace(/^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)[a-z]*/, '$1-$2-$3T$4:$5:$6Z'), attributedTo: `${PUBLIC_URL}/discdag/user/${author}`, inReplyTo: [], replies: [], content, }; } for (const node of doc('g.edge', 'svg')) { let [frm, to] = doc('title', node).text().split('->'); frm = `${PUBLIC_URL}/discdag/disc/${discussion}/${frm}`; to = `${PUBLIC_URL}/discdag/disc/${discussion}/${to}`; items[frm].replies.push(to); items[to].inReplyTo.push(frm); } return $res.json({ '@context': [ 'https://www.w3.org/ns/activitystreams', { items: { '@id': 'as:items', '@container': '@id' }, }, ], id: `${PUBLIC_URL}/discdag/disc/${discussion}`, type: 'Document', name: discussion, first, items, }); })) .post('/disc/:discussion', wrap(async ($req, $res) => { const { discussion } = $req.params; const { digest } = $req.cookies; if (!digest || !digest.length) return $res.status(401).end(); type Activity = | Create | Create> | Delete> ; const activity = $req.body as Activity; let selection: string[]; let body: string; let method = 'GET'; if (activity.type === 'Create' && activity.object?.type === 'Note') { method = 'POST'; selection = activity.object.inReplyTo.map(iri2id); body = new URLSearchParams({ Action: 'SendReply', ReplyText: activity.object.content }).toString(); } else if (activity.object?.type === 'Relationship' && activity.object.relationship === 'as:inReplyTo') { const frm = iri2id(activity.object.object); const to = iri2id(activity.object.subject); selection = [frm, to]; body = new URLSearchParams({ Action: activity.type === 'Create' ? 'CreateLink' : 'RemoveLink', N0: frm, N1: to, }).toString(); } else { return $res.status(501).end(); } const headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Cookie': `DiscussionID=${discussion}; digest=${digest}; SelectedNodes=${selection.join(',')}`, }; let res; if (method === 'POST') { res = await fetch(DISCDAG_URL, { method, headers, body }); } else { res = await fetch(DISCDAG_URL + `?${body}`, { method, headers }); } if (res.status !== 200) throw new Error("bad response"); const doc = cheerio.load(await res.text()); if (doc.text().indexOf("Invalid discussion ID:") > -1) return $res.status(404).end(); if (doc.text().indexOf("Replying only available if you have write access:") > -1) return $res.status(403).end(); const items: Record = {}; for (const node of doc('g.node polygon[stroke-width=5]', 'svg').parent()) { const nodeId = doc('title', node).text(); const [_, time, author] = nodeId.split('_'); const id = `${PUBLIC_URL}/discdag/disc/${discussion}/${nodeId}`; const content = doc('g:first text', node) .slice(3) .map(function(this: any) { return doc(this).text(); }) .toArray() .join(' ').trim(); items[id] = { type: 'Note', published: time.replace(/^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)[a-z]*/, '$1-$2-$3T$4:$5:$6Z'), attributedTo: `${PUBLIC_URL}/discdag/user/${author}`, inReplyTo: [], replies: [], content, }; } for (const node of doc('g.edge', 'svg')) { let [frm, to] = doc('title', node).text().split('->'); if (frm === 'SELECTED' || to === 'SELECTED') continue; frm = `${PUBLIC_URL}/discdag/disc/${discussion}/${frm}`; to = `${PUBLIC_URL}/discdag/disc/${discussion}/${to}`; items[frm]?.replies.push(to); items[to]?.inReplyTo.push(frm); } return $res.json({ '@context': [ 'https://www.w3.org/ns/activitystreams', { items: { '@id': 'as:items', '@container': '@id' }, }, ], items, }); })) ; app.use('/discdag', discdag); app.get(/\/remote\/.*/, wrap(async ($req, $res) => { const { origin, referer } = $req.headers; const url = $req.url.match('^/remote/(.*)')![1]; if (!DEBUG) { const org = origin ?? new URL(referer!).origin; if (org !== PUBLIC_URL) return $res.status(403).end(); } const res = await fetch(url, { headers: { 'Accept': 'application/ld+json, application/json', 'X-Forwarded-For': $req.ip, }, }); $res.status(res.status); for (const key of [ 'Content-Type', 'Content-Size', 'Location', 'Age', 'Cache-Control', 'Expires', 'Etag', ]) { const val = res.headers.get(key); if (val) $res.setHeader(key, val); } res.body.pipe($res); return $res; })); try { app.listen(PORT, (): void => { console.log(`Connected successfully on port ${PORT}`); }); } catch (error) { console.error(`Error occured: ${error}`); }