import express from "express"; import { config as dotenvConfig } from "dotenv"; import path from "path"; import SQLite from 'better-sqlite3'; import { Kysely, SqliteDialect } from 'kysely'; import { nanoid } from "nanoid"; import { JSDOM } from "jsdom"; import Parser from "rss-parser"; import nacl from "tweetnacl"; import expressBasicAuth from "express-basic-auth"; import { getRelativeTime } from "./modules/relativeTime.js"; import { fromHex } from "./modules/fromHex.js"; import sitemap from "./sitemap.json" assert { type: "json" }; dotenvConfig(); const __dirname = import.meta.dirname; const rawDB = new SQLite('abtmtr.db'); const db = new Kysely({ dialect: new SqliteDialect({ database: rawDB, }) }); const rssParser = new Parser(); rawDB.exec(`CREATE TABLE IF NOT EXISTS blurbs( 'id' TEXT, 'site' TEXT, 'blurb' TEXT, 'verified' INTEGER, 'time' INTEGER );`); rawDB.exec(`CREATE TABLE IF NOT EXISTS mininq( 'id' TEXT, 'name' TEXT, 'ip' TEXT, 'msg' TEXT, 'url' TEXT, 'public' INTEGER, 'reply' TEXT, 'time' INTEGER );`); rawDB.exec(`CREATE TABLE IF NOT EXISTS blacklist( 'domain' TEXT );`); const app = express(); app.set('trust proxy', true); app.set('view engine', 'ejs'); app.use('/assets', express.static('assets')); app.set('views', path.join(__dirname, "views", "pages")); app.use(express.urlencoded({ extended: true })) app.locals.siteMap = sitemap; app.locals.curCommit = "0000000000"; async function getCurCommit() { const curCommit = await fetch("https://git.abtmtr.link/api/v1/repos/MeowcaTheoRange/abtmtr-v13/branches/main") .catch(() => res.status(500).send()) .then((res) => res.json()); app.locals.curCommit = curCommit.commit.id.substr(0, 10); } getCurCommit(); app.get('/', async (req, res) => { const statusesJson = await fetch("https://cdn.abtmtr.link/site_content/v13/statuses.json") .catch(() => res.status(500).send()) .then((res) => res.json()); res.render('index', { statuses: statusesJson }); }) app.get('/servers', async (req, res) => { const servicesJson = await fetch("https://cdn.abtmtr.link/site_content/v13/services.json") .catch(() => res.status(500).send()) .then((res) => res.json()); const computersJson = await fetch("https://cdn.abtmtr.link/site_content/v13/computers.json") .catch(() => res.status(500).send()) .then((res) => res.json()); res.render('servers', { services: servicesJson, computers: computersJson, visibility: [ "for domain performance", "for personal use", "for use by friends of abtmtr.link's webmaster(s)", "for restricted public use", "for public use" ] }); }); app.get('/about', async (req, res) => { const linksJson = await fetch("https://cdn.abtmtr.link/site_content/v13/links.json") .catch(() => res.status(500).send()) .then((res) => res.json()); const currentlyListeningJson = await fetch(`https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=MeowcaTheoRange&api_key=${process.env.LFM_API_KEY}&format=json&limit=2&extended=1`) .catch(() => res.status(500).send()) .then((res) => res.json()); res.render('about', { links: linksJson, cl: currentlyListeningJson.recenttracks.track[0], getRelativeTime }); }); app.get('/characters', async (req, res) => { const charactersJson = await fetch("https://cdn.abtmtr.link/site_content/v13/characters.json") .catch(() => res.status(500).send()) .then((res) => res.json()); res.render('characters', { characters: charactersJson }); }); app.get('/mininq', async (req, res) => { const mailbox = await db .selectFrom('mininq') .select('id') .where('public', '=', 0) .where('url', '=', '') .orderBy('time', "desc") .limit(process.env.MININQ_MAILBOX_MAX) .execute(); const verifiedMailbox = await db .selectFrom('mininq') .select('id') .where('public', '=', 0) .where('url', '!=', '') .orderBy('time', "desc") .limit(process.env.MININQ_VERIFIED_MAILBOX_MAX) .execute(); const isIpAlreadyMininq = await db .selectFrom('mininq') .select('ip') .where('ip', '=', req.ip) .where('public', '=', 0) .executeTakeFirst(); const { publicKey, secretKey } = nacl.box.keyPair(); const data = await db .selectFrom('mininq') .selectAll() .where('public', '=', 1) .orderBy('time', "desc") .execute(); res.render('mininq', { keypair: { publicKey: Buffer.from(publicKey).toString('hex'), secretKey: Buffer.from(secretKey).toString('hex'), }, data, mailboxCount: mailbox.length, isIpAlreadyMininq, mailboxVerifiedCount: verifiedMailbox.length, mailboxMaximum: process.env.MININQ_MAILBOX_MAX, mailboxVerifiedMaximum: process.env.MININQ_VERIFIED_MAILBOX_MAX }); }); async function checkIsMtr(user, pass, cb) { try { let publicKeyWorks = await checkPublicKey({ skey: pass, pkeyurl: process.env.VERIF_URL }); return cb(null, publicKeyWorks); } catch (err) { return cb(null, false); } } app.get('/mininq/mbox', expressBasicAuth({ authorizer: checkIsMtr, authorizeAsync: true, challenge: true }), async (req, res) => { const mailbox = await db .selectFrom('mininq') .selectAll() .where('public', '=', 0) .where('url', '=', '') .orderBy('time', "desc") .execute(); const verifiedMailbox = await db .selectFrom('mininq') .selectAll() .where('public', '=', 0) .where('url', '!=', '') .orderBy('time', "desc") .execute(); res.render('mininqbox', { mail: verifiedMailbox.concat(mailbox), mailboxCount: mailbox.length, mailboxVerifiedCount: verifiedMailbox.length, mailboxMaximum: process.env.MININQ_MAILBOX_MAX, mailboxVerifiedMaximum: process.env.MININQ_VERIFIED_MAILBOX_MAX }); }); app.post('/mininq/mbox/r', async (req, res) => { const body = req.body; if (typeof body.skey != "string") return res.status(400).send("skey is not a string"); if (body.skey.length != 64) return res.status(400).send("skey must be a valid secret key"); try { let publicKeyWorks = await checkPublicKey({ skey: body.skey, pkeyurl: process.env.VERIF_URL }); if (!publicKeyWorks) return res.status(401).send("No?"); } catch (err) { return res.status(400).send(err.message); } if (typeof body.id != "string") return res.status(400).send("id is not a string"); if (body.id.length != 32) return res.status(400).send("id must be a mininq ID"); if (typeof body.action != "string") return res.status(400).send("action is not a string"); if (body.action.length > 10) return res.status(400).send("action must be valid"); switch (body.action) { case "public": if (process.env.MININQ_WEBHOOK != null) { const mininq_pub = await db .selectFrom('mininq') .selectAll() .where('id', '=', body.id) .executeTakeFirst(); await fetch(process.env.MININQ_WEBHOOK, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ "content": "New mininq:", "embeds": [ { "description": mininq_pub.msg, "color": 12298956, // TODO: make this smaller "author": mininq_pub.url != '' ? { "name": `${mininq_pub.name} @ ${new URL(mininq_pub.url).host} sent:`, "url": `https://abtmtr.link/mininq/#${mininq.id}` } : { "name": `${mininq_pub.name} sent:`, "url": `https://abtmtr.link/mininq/#${mininq.id}` }, "timestamp": new Date(mininq_pub.time).toISOString() } ], "attachments": [] }) }); } await db .updateTable('mininq') .set({ public: 1 }) .where('id', '=', body.id) .where('public', '=', 0) .executeTakeFirst(); break; case "delete": await db .deleteFrom('mininq') .where('id', '=', body.id) .executeTakeFirst(); break; default: if (typeof body.msg != "string") return res.status(400).send("msg is not a string"); if (body.msg.length < 1) return res.status(400).send("msg is required"); if (body.msg.length > 4000) return res.status(400).send("msg must not be longer than 4000 characters"); if (process.env.MININQ_WEBHOOK != null) { const mininq = await db .selectFrom('mininq') .selectAll() .where('id', '=', body.id) .executeTakeFirst(); await fetch(process.env.MININQ_WEBHOOK, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ "content": "New mininq:", "embeds": [ { "description": mininq.msg, "color": 12298956, "author": mininq.url != '' ? { "name": `${mininq.name} @ ${new URL(mininq.url).host} sent:`, "url": `https://abtmtr.link/mininq/#${mininq.id}` } : { "name": `${mininq.name} sent:` }, "timestamp": new Date(mininq.time).toISOString() }, { "description": body.msg, "color": 13421772, "author": { "name": "sysadmin reply:" } } ], "attachments": [] }) }); } await db .updateTable('mininq') .set({ public: 1, reply: body.msg }) .where('id', '=', body.id) .where('public', '=', 0) .executeTakeFirst(); break; } res.redirect('/mininq/mbox'); }); async function checkPublicKey(body) { if (typeof body.skey != "string") throw ("skey is not a string"); if (body.skey.length != 64) throw ("skey must be a valid secret key"); if (body.pkeyurl.length < 1) throw ("pkeyurl is required"); if (body.pkeyurl.length > 512) throw ("pkeyurl must not be longer than 512 characters"); const site = await fetch(body.pkeyurl).then(async x => ({ s: x.status, t: await x.text() })).catch(_ => _); if (site.s != 200) throw ("pkeyurl's site is not online"); try { const domain = new URL(body.pkeyurl).hostname; const isBadSite = await db .selectFrom('blacklist') .selectAll() .where('domain', '=', domain) .executeTakeFirst(); if (isBadSite != null) throw ({ error: "Forbidden", message: "\u{1f595} (or don't authenticate, i guess)" }); } catch (err) { throw ({ error: "Internal Server Error", message: "URL CONSTRUCTOR FAILED???? i think this might be your fault...." }); } let relationMeta; try { let webDom = new JSDOM(site.t); relationMeta = webDom.window.document.querySelector( `meta[name="abtmtr-mininq-key"]` ); } catch (err) { throw ("Something went wrong parsing your site's DOM"); } let publicKey; if (relationMeta != null) publicKey = relationMeta.content; else throw ("Can't find public key"); let pkMatches; try { const generatedPublicKey = nacl.box.keyPair.fromSecretKey(fromHex(body.skey)); pkMatches = Buffer.from(generatedPublicKey.publicKey).toString('hex') == publicKey; } catch (err) { throw ("Something went wrong verifying your keypair"); } return pkMatches; } app.post('/mininq/send', async (req, res) => { const isIpAlreadyMininq = await db .selectFrom('mininq') .select('ip') .where('ip', '=', req.ip) .where('public', '=', 0) .executeTakeFirst(); if (isIpAlreadyMininq != null) return res.status(403).send("IP is already in mailbox"); const body = req.body; let newObject = { url: '', id: nanoid(32), public: 0, reply: '', ip: req.ip }; if (typeof body.name != "string") return res.status(400).send("name is not a string"); if (body.name.length < 1) return res.status(400).send("name is required"); if (body.name.length > 40) return res.status(400).send("name must not be longer than 40 characters"); newObject.name = body.name; if (typeof body.msg != "string") return res.status(400).send("msg is not a string"); if (body.msg.length < 1) return res.status(400).send("msg is required"); if (body.msg.length > 2000) return res.status(400).send("msg must not be longer than 2000 characters"); newObject.msg = body.msg; if (typeof body.pkeyurl == "string") { try { let publicKeyWorks = await checkPublicKey(body); if (publicKeyWorks) { newObject.url = body.pkeyurl; const isUrlAlreadyMininq = await db .selectFrom('mininq') .select('url') .where('url', '=', newObject.url) .where('public', '=', 0) .executeTakeFirst(); if (isUrlAlreadyMininq != null) return res.status(403).send("Pubkey is already in mailbox"); } } catch (err) { // do nothing lol } } const mailbox = await db .selectFrom('mininq') .select('id') .where('public', '=', 0) .where('url', '=', '') .orderBy('time', "desc") .limit(process.env.MININQ_MAILBOX_MAX) .execute(); const verifiedMailbox = await db .selectFrom('mininq') .select('id') .where('public', '=', 0) .where('url', '!=', '') .orderBy('time', "desc") .limit(process.env.MININQ_VERIFIED_MAILBOX_MAX) .execute(); if (newObject.url) { if (verifiedMailbox.length >= process.env.MININQ_VERIFIED_MAILBOX_MAX) return res.status(403).send("Mailbox is full"); } else { if (mailbox.length >= process.env.MININQ_MAILBOX_MAX) return res.status(403).send("Mailbox is full"); } newObject.time = Date.now(); try { await db.insertInto('mininq') .values(newObject) .executeTakeFirstOrThrow(); } catch (err) { console.log(err) return res.status(500).send('Internal Server Error'); } res.redirect('/mininq/'); }); app.get('/updates', async (req, res) => { const rssXML = await fetch("https://cdn.abtmtr.link/site_content/rss.xml") .catch(() => res.status(500).send()) .then((res) => res.text()); const rssParsed = await rssParser.parseString(rssXML); res.render('updates', { rss: rssParsed }); }); app.get('/sites', async (req, res) => { const buttonsJson = await fetch("https://cdn.abtmtr.link/site_content/buttons.json") .catch(() => res.status(500).send()) .then((res) => res.json()) .catch(() => res.status(500).send()); const followingJson = await fetch("https://cdn.abtmtr.link/site_content/following.json") .catch(() => res.status(500).send()) .then((res) => res.json()) .catch(() => res.status(500).send()); res.render('sites', { b: buttonsJson, f: followingJson }); }); app.get('/blurbs', async (req, res) => { const data = await db .selectFrom('blurbs') .selectAll() .where('verified', '=', 1) .orderBy('time', "desc") .execute(); res.render('blurbs', { data }); }); // brbrleibbghbelsbbsbuuebbuubsubss async function cleanupBlurbles() { const timeNow = Date.now() - 86400000; await db .deleteFrom('blurbs') .where('time', '<', timeNow) .where('verified', '=', 0) .execute() } app.get('/blurbs/testsend', async (req, res) => { res.render('blurbsent', { id: "THISISATESTLOL" }); }); app.get('/blurbs/send', async (req, res) => { const body = req.query; const errors = []; if (body.site == null) errors.push("Site domain required"); else { const status = await fetch(body.site).then(x => x.status).catch(_ => _); if (status != 200) errors.push("Site must be online"); } if (body.text == null) errors.push("Blurb text required"); else { if (body.text.length < 1) errors.push("Blurb text must not be blank"); if (body.text.length > 140) errors.push("Blurb text must not exceed 140 characters"); } if (errors.length > 0) return res.status(400).json({ error: "Bad Request", message: errors.join(", ") }); try { const domain = new URL(body.site).hostname; const isBadSite = await db .selectFrom('blacklist') .selectAll() .where('domain', '=', domain) .executeTakeFirst(); if (isBadSite != null) return res.status(403).json({ error: "Forbidden", message: "\u{1f595}" }); } catch (err) { return res.status(500).json({ error: "Internal Server Error", message: "URL CONSTRUCTOR FAILED???? i think this might be your fault...." }); } const postId = nanoid(32); const timestamp = Date.now(); try { await db.insertInto('blurbs') .values({ id: postId, site: body.site, blurb: body.text, verified: 0, time: timestamp }) .executeTakeFirstOrThrow(); } catch (err) { console.log(err) return res.status(500).send('Internal Server Error'); } cleanupBlurbles(); res.render('blurbsent', { id: postId }); }); app.get('/blurbs/check', async (req, res) => { const body = req.query; const blurbFromId = await db .selectFrom('blurbs') .selectAll() .where('id', '=', body.id) .executeTakeFirst(); if (blurbFromId == null) return res.redirect("/blurbs/"); const site = await fetch(blurbFromId.site).then(x => x.text()).catch(_ => _); const dom = new JSDOM(site); const relationLink = dom.window.document.querySelector( `[rel=me][href="https://abtmtr.link/blurbs/#${blurbFromId.id}"]` ); if (relationLink != null && blurbFromId.verified != 1) { await db .updateTable('blurbs') .set({ verified: 1 }) .where('id', '=', body.id) .executeTakeFirst(); if (process.env.BLURBS_WEBHOOK != null) { await fetch(process.env.BLURBS_WEBHOOK, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ "content": "New blurb:", "embeds": [ { "description": blurbFromId.blurb, "color": 12298956, "author": { "name": `${new URL(blurbFromId.site).host}`, "url": `https://abtmtr.link/blurbs/#${blurbFromId.id}` }, "timestamp": new Date(blurbFromId.time).toISOString() } ], "attachments": [] }) }); } } else if (relationLink == null) { await db .updateTable('blurbs') .set({ verified: 0 }) .where('id', '=', body.id) .executeTakeFirst(); } cleanupBlurbles(); res.redirect("/blurbs/"); }); app.get("/favicon.ico", (req, res) => { res.redirect("https://cdn.abtmtr.link/site_content/favicon.ico") }) app.get(["/rss", "/rss.xml"], (req, res) => { res.redirect("https://cdn.abtmtr.link/site_content/rss.xml") }) app.all('*', (req, res) => { res.status(404).render('404'); }) app.listen(process.env.PORT, () => { const url = new URL("http://localhost/"); url.port = process.env.PORT; console.log(`Example app listening on ${url.toString()}`); });