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