"use strict"; const Promise = require("bluebird"); const express = require("express"); const bhttp = require("bhttp"); const knex = require("knex"); const expressReactViews = require("@joepie91/express-react-views"); const expressSession = require("express-session"); const KnexSessionStore = require("connect-session-knex")(expressSession); const expressPromiseRouter = require("express-promise-router"); const bodyParser = require("body-parser"); const path = require("path"); const url = require("url"); const createError = require("create-error"); const stripTags = require("striptags"); const escapeStringRegexp = require("escape-string-regexp"); const configuration = require(process.env.PACT_CONFIG_FILE ?? "./configuration.json"); const createRateLimiter = require("./rate-limiter"); const normalizeAddress = require("./normalize-address"); const lookupUser = require("./lookup-user"); const hashtagRegex = new RegExp(`${escapeStringRegexp(configuration.hashtag)}\\b`); const AdminValidationError = createError("AdminValidationError"); const NotSignedError = createError("NotSignedError"); const SignatureExistsError = createError("SignatureExistsError"); const InvalidInputError = createError("InvalidInputError"); const RateLimitExceeded = createError("RateLimitExceeded"); // Max 10 submissions per IP let rateLimiter = createRateLimiter(10); let db = knex(require("./knexfile")); function checkExistence(submitter, hostname) { return Promise.try(() => { let query = db("signatories").where({ submitter: submitter }); if (hostname != null) { query = query.orWhere({ hostname: hostname }); } return query; }).then((results) => { if (results.length > 0) { return true; } else { return false; } }); } function requireAdmin(req, res, next) { if (req.session.loggedIn) { next(); } else { res.redirect("/login"); } } let app = express(); app.set("trust proxy", "loopback"); app.set(path.resolve(__dirname, "./views")); app.set("view engine", "jsx"); app.engine("jsx", expressReactViews.createEngine()); app.use(express.static(path.resolve(__dirname, "./static"))); app.use(expressSession({ secret: configuration.sessionSecret, resave: false, saveUninitialized: false, store: new KnexSessionStore({ knex: db }) })); app.use(bodyParser.urlencoded({ extended: false })); let router = expressPromiseRouter(); router.get("/", (req, res) => { return Promise.try(async () => { let signatories = await (db("signatories").where({ isVerified: true })); res.render("index", { hashtag: configuration.hashtag, signatories: signatories }); }); }); // GET requests (see also 3.2 Retrieving objects) with an Accept header of application/ld+json; profile="https://www.w3.org/ns/activitystreams". router.post("/submit", (req, res) => { const { testValue } = require("@validatem/core"); const required = require("@validatem/required"); const isNonEmptyString = require("@validatem/is-non-empty-string"); const allowExtraProperties = require("@validatem/allow-extra-properties"); return Promise.try(async () => { let validInput = testValue(req.body, { submitter: [ required, isNonEmptyString ] }); if (!validInput) { throw new InvalidInputError("invalid input"); } let address = normalizeAddress(req.body.submitter); let user = await lookupUser(address); let exists = await checkExistence(address, user.hostname); if (exists) { throw new SignatureExistsError(`Already signed`); } if (user.apiURL == null) { throw new AdminValidationError(`no webfinger response`, { submitter: address }); } let instanceResponse = await bhttp.get(user.apiURL, { headers: { "User-Agent": "anti-meta-pact signatory verifier (trouble: admin@cryto.net)" } }); if (instanceResponse.statusCode !== 200) { throw new AdminValidationError(`invalid response code`, { submitter: address }); } let validResponse = testValue(instanceResponse.body, allowExtraProperties({ contact_account: allowExtraProperties({ username: [ required, isNonEmptyString ], note: [ required, isNonEmptyString ] }), uri: [ required, isNonEmptyString ] })); if (!validResponse) { throw new AdminValidationError(`invalid API response`, { submitter: address }); } let uri = instanceResponse.body.uri; let domain = uri.includes("://") ? url.parse(uri).host // Workaround for GoToSocial bug : uri; let adminID = `${instanceResponse.body.contact_account.username}@${domain}`; let bio = instanceResponse.body.contact_account.note; // Hacky way to not break the regex matching (as just stripping tags altogether might remove spaces necessary for word boundaries) if (!hashtagRegex.test(stripTags(bio, ["p", "div"]))) { throw new NotSignedError(`Signature not found in bio`); } if (!rateLimiter.tryMark(req.ip)) { throw new RateLimitExceeded(`Rate limit exceeded`); } await db("signatories").insert({ submitter: address, admin: adminID, hostname: domain, isVerified: true, isManual: false, addedDate: new Date() }); res.send("Thanks! We've successfully verified your signature, and you've been added to the list."); }).catch(AdminValidationError, async (error) => { await db("signatories").insert({ submitter: error.submitter, isVerified: false, isManual: false, addedDate: new Date() }); res.send(`Thanks! We could not automatically validate your submission (${error.message}), but we've added it to the manual approval list.`); }).catch(SignatureExistsError, (_error) => { res.send(`Error: We already have a submission for this instance; if it hasn't appeared on the site yet, it's still in queue to be approved manually!`); }).catch(NotSignedError, (_error) => { res.send(`Error: We could not find the hashtag in your admin account's bio; please add it, and try again.`); }).catch(InvalidInputError, (_error) => { res.send("Error: you need to enter your fedi ID to sign this! Please go back and try again."); }).catch(RateLimitExceeded, (_error) => { res.send("You've already submitted 10 entries; you cannot submit any more. Please contact us if you really do run more instances than that."); }); }); router.get("/login", (req, res) => { res.render("login"); }); router.post("/login", (req, res) => { return Promise.try(async () => { if (req.body.password === configuration.adminPassword) { req.session.loggedIn = true; await Promise.promisify(req.session.save.bind(req.session))(); res.redirect("/admin"); } else { res.send("Invalid password"); } }); }); router.get("/admin", requireAdmin, (req, res) => { return Promise.try(async () => { let queue = await db("signatories").where({ isVerified: false }); res.render("admin", { queue: queue }); }); }); router.post("/approve", requireAdmin, (req, res) => { return Promise.try(async () => { if (req.body.decision === "approve") { await db("signatories") .update({ isVerified: true, admin: req.body.submitter }) .where({ submitter: req.body.submitter }); res.redirect("/admin"); } else if (req.body.decision === "reject") { await db("signatories") .delete() .where({ submitter: req.body.submitter }); res.redirect("/admin"); } else { throw new Error(`Unexpected decision value`); } }); }); router.post("/add", requireAdmin, (req, res) => { return Promise.try(async () => { await db("signatories").insert({ submitter: normalizeAddress(req.body.address), admin: normalizeAddress(req.body.address), isVerified: true, isManual: true, addedDate: new Date() }); res.redirect("/admin"); }); }); app.use(router); app.use((req, res) => { res.status(404).send("Not found"); }); app.use((error, req, res, _next) => { console.error(error.message); console.error(error.stack); res.status(500).send("Internal server error"); }); app.listen(3500, () => { console.log("Server running at port 3500"); });