Initial commit

master
Sven Slootweg 11 months ago
commit 0c6c58df62

@ -0,0 +1 @@
{ "extends": "@joepie91/eslint-config/react" }

4
.gitignore vendored

@ -0,0 +1,4 @@
node_modules
sandbox.js
data.db
configuration.json

@ -0,0 +1,6 @@
{
"hashtag": "#TestTag",
"databaseFile": "./data.db",
"sessionSecret": "abcde",
"adminPassword": "changeme123"
}

@ -0,0 +1,265 @@
"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(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(req.body.submitter);
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");
});

@ -0,0 +1,10 @@
"use strict";
const path = require("path");
const configuration = require("./configuration.json");
module.exports = {
client: "sqlite3",
connection: { filename: path.resolve(__dirname, configuration.databaseFile) },
useNullAsDefault: true
};

@ -0,0 +1,36 @@
"use strict";
const Promise = require("bluebird");
const WebFinger = require("webfinger.js");
const url = require("url");
let webfinger = Promise.promisifyAll(new WebFinger());
module.exports = function lookupUser(address) {
return Promise.try(() => {
return webfinger.lookupAsync(address);
}).then((response) => {
let aliasCount = response?.object?.aliases?.length;
if (aliasCount == null || aliasCount === 0) {
return {};
} else {
let { hostname, port } = url.parse(response.object.aliases[0]);
return {
aliases: response.object.aliases,
hostname: hostname,
apiURL: url.format({
protocol: "https",
slashes: true,
hostname: hostname,
port: port,
pathname: "/api/v1/instance"
})
};
}
}).catch((error) => {
console.log({error});
return {};
});
};

@ -0,0 +1,17 @@
"use strict";
module.exports.up = function(knex, Promise) {
return knex.schema.createTable("signatories", (table) => {
table.increments("id").notNullable();
table.string("hostname").nullable();
table.string("submitter").notNullable();
table.string("admin").nullable();
table.boolean("isVerified").notNullable();
table.boolean("isManual").notNullable();
table.timestamp("addedDate").notNullable();
});
}
module.exports.down = function(knex, Promise) {
return knex.schema.dropTable("signatories");
}

@ -0,0 +1,5 @@
"use strict";
module.exports = function normalizeAddress(address) {
return address.replace(/^@/, "");
};

@ -0,0 +1,35 @@
{
"name": "anti-meta-pact",
"version": "1.0.0",
"main": "index.js",
"repository": "git@git.cryto.net:joepie91/anti-meta-pact.git",
"author": "Sven Slootweg <admin@cryto.net>",
"license": "MIT",
"dependencies": {
"@joepie91/express-react-views": "^2.0.0",
"@validatem/allow-extra-properties": "^0.1.0",
"@validatem/core": "^0.4.0",
"@validatem/is-non-empty-string": "^0.1.0",
"@validatem/required": "^0.1.1",
"bhttp": "^1.2.8",
"bluebird": "^3.7.2",
"body-parser": "^1.20.2",
"connect-session-knex": "^3.0.1",
"create-error": "^0.3.1",
"escape-string-regexp": "^4",
"express": "^4.18.2",
"express-promise-router": "^4.1.1",
"express-session": "^1.17.3",
"knex": "^2.4.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sqlite3": "^5.1.6",
"striptags": "^3.2.0",
"webfinger.js": "^2.7.1"
},
"devDependencies": {
"@joepie91/eslint-config": "^1.1.1",
"eslint": "^8.43.0",
"hakk": "^0.0.22-alpha"
}
}

@ -0,0 +1,32 @@
"use strict";
const net = require("net");
function normalizeAddress(address) {
let family = address.includes(":")
? "ipv6"
: "ipv4";
return (new net.SocketAddress({ address: address, family: family })).address;
}
module.exports = function createRateLimiter(cap) {
let ips = new Map();
return {
tryMark: function (ip) {
let normalized = normalizeAddress(ip);
let current = ips.has(normalized)
? ips.get(normalized)
: 0;
if (current >= cap) {
return false;
} else {
ips.set(normalized, current + 1);
return true;
}
}
};
};

@ -0,0 +1,42 @@
"use strict";
const React = require("react");
const Layout = require("./layout");
module.exports = function AdminPanel({ queue }) {
return (
<Layout>
<h2>Admin panel</h2>
<h3>Add signatory manually</h3>
<p>Note: no further verification takes place for this!</p>
<form action="/add" method="post">
<label htmlFor="address">Admin ID:</label>
<input type="text" name="address" id="address" />
<button type="submit">Add</button>
</form>
<h3>Verification queue</h3>
<p>Note: approving an entry in this list causes the submitter ID to be shown as the admin, without further verification whether they actually are! Manually add them instead, if the actual admin account is a different one than the submitter.</p>
<ul>
{queue.map((item) => {
return (
<li key={item.submitter}>
{item.submitter}
<form style={{ display: "inline" }} action="/approve" method="post">
<input type="hidden" name="submitter" value={item.submitter} />
<button type="submit" name="decision" value="approve">Approve</button>
<button type="submit" name="decision" value="reject">Reject</button>
</form>
</li>
);
})}
</ul>
</Layout>
);
};

@ -0,0 +1,48 @@
"use strict";
const React = require("react");
const Layout = require("./layout");
module.exports = function Index({ signatories, hashtag }) {
return (
<Layout>
<p>
If you're an instance admin and want to sign the pact:
</p>
<ol>
<li>Add the {hashtag} hashtag to your admin account's bio</li>
<li>Fill in your fedi ID below</li>
<li>Hit submit</li>
</ol>
<p>
We'll try to automatically verify your submission; this only works if you're running Mastodon or something with a compatible API. Otherwise, we'll still store your submission, but we'll verify it manually. We may contact you at the ID you've provided if we need more information!
</p>
<p>(If you're not the admin, you can also submit your ID below; but we'll still check the <em>admin account</em>'s bio for the hashtag! If we need to contact you, we'll use the ID you've entered.)</p>
<form action="/submit" method="post">
<label htmlFor="submitter">Your fedi ID:</label>
<input type="text" name="submitter" id="submitter" placeholder="@you@example.com" />
<button type="submit">Submit</button>
</form>
<hr />
<h2>Signed by:</h2>
<ul>
{signatories.map((signatory) => {
return (
<li key={signatory.admin}>
<strong>{signatory.submitter.split("@")[1]}</strong>&nbsp;
({signatory.admin})
</li>
);
})}
</ul>
</Layout>
);
};

@ -0,0 +1,19 @@
"use strict";
const React = require("react");
module.exports = function Layout({ children }) {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ANTI-META ADMIN PACT</title>
</head>
<body>
{children}
</body>
</html>
);
};

@ -0,0 +1,17 @@
"use strict";
const React = require("react");
const Layout = require("./layout");
module.exports = function Login() {
return (
<Layout>
<form action="/login" method="post">
<label htmlFor="password">Password:</label>
<input type="text" name="password" id="password" />
<button type="submit">Login</button>
</form>
</Layout>
);
};

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save