abtmtr-v13/index.js

772 lines
No EOL
22 KiB
JavaScript

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 crypto from "node:crypto";
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
});
});
if (process.env.NODE_ENV === "development") {
const enc_key = Buffer.from(process.env.RESUME_ENCRYPTION_KEY, "hex");
app.get('/resumeEnc', async (req, res) => {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
"aes-256-cbc", enc_key, iv
);
let encrypted = cipher.update(req.query.text, 'utf8', 'hex');
encrypted += cipher.final('hex');
res.header("Content-type", "text/plain").send(encrypted + "\n" + iv.toString('hex'));
});
}
function decipher(file, key) {
let hexKey;
try {
hexKey = Buffer.from(key, "hex");
} catch (err) {
throw err;
}
const [enc, iv] = file.split("\n");
let decipher;
try {
decipher = crypto.createDecipheriv(
"aes-256-cbc", hexKey, Buffer.from(iv, "hex")
);
} catch (err) {
throw err;
}
let decrypted = decipher.update(enc, 'hex', 'utf8');
decrypted += decipher.final('utf8');
let decryptedJSON;
try {
decryptedJSON = JSON.parse(decrypted);
} catch (err) {
throw err;
}
return decryptedJSON;
}
app.get('/resume', async (req, res) => {
if (req.query.key == null || req.query.key.length < 1) return res.render('resumeEntry', { badError: false });
const encryptedPD = await fetch(process.env.RESUME_DATA)
.catch(() => res.status(500).send())
.then((res) => res.text());
const encryptedJobs = await fetch(process.env.RESUME_JOBS)
.catch(() => res.status(500).send())
.then((res) => res.text());
const encryptedEducation = await fetch(process.env.RESUME_EDU)
.catch(() => res.status(500).send())
.then((res) => res.text());
const unencryptedOrgs = await fetch(process.env.RESUME_ORGS)
.catch(() => res.status(500).send())
.then((res) => res.json());
let unencryptedPD;
try {
unencryptedPD = decipher(encryptedPD, req.query.key);
} catch (err) {
console.error(err);
return res.render('resumeEntry', { badError: true });
}
let unencryptedJobs;
try {
unencryptedJobs = decipher(encryptedJobs, req.query.key);
} catch (err) {
console.error(err);
return res.render('resumeEntry', { badError: true });
}
let unencryptedEducation;
try {
unencryptedEducation = decipher(encryptedEducation, req.query.key);
} catch (err) {
console.error(err);
return res.render('resumeEntry', { badError: true });
}
if (req.query.raw && process.env.NODE_ENV === "development")
return res.json({
pd: unencryptedPD,
jobs: unencryptedJobs,
education: unencryptedEducation,
orgs: unencryptedOrgs,
// desc: unencryptedDesc
});
res.render('resume', {
pd: unencryptedPD,
jobs: unencryptedJobs,
education: unencryptedEducation,
orgs: unencryptedOrgs,
// desc: unencryptedDesc
});
});
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()}`);
});