You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

268 lines
7.8 KiB
JavaScript

"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");
});