pasu/server.js

252 lines
7.2 KiB
JavaScript

import "dotenv/config.js";
import fs from "fs-extra";
import path from "path";
import crypto from "crypto";
import { performance } from "perf_hooks";
import express from "express";
import rateLimit from "express-rate-limit";
import cookieParser from "cookie-parser";
import { Fido2Lib } from "fido2-lib";
import getIpInfo from "./src/get-ip-info.js";
import getRegister from "./src/get-register.js";
import postRegister from "./src/post-register.js";
import getLogin from "./src/get-login.js";
import postLogin from "./src/post-login.js";
const {
SERVER_ADDR = "0.0.0.0",
SERVER_PORT = 3000,
SERVER_NAME,
BLACKLIST_UA,
WHITELIST_COUNTRY,
ENABLE_FIDO2,
ALLOW_REGISTER,
} = process.env;
if (!fs.existsSync("data/latest.json")) fs.outputFileSync("data/latest.json", JSON.stringify([]));
fs.ensureDirSync("registered");
fs.ensureDirSync("session");
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", 1);
app.set("view engine", "ejs");
app.set("views", path.resolve("./view"));
app.locals.f2l =
ENABLE_FIDO2 &&
new Fido2Lib({
timeout: 60000,
rpId: SERVER_NAME,
rpName: SERVER_NAME,
rpIcon: `https://${SERVER_NAME}/favicon.png`,
challengeSize: 128,
attestation: "direct",
cryptoParams: [-7, -35, -36, -257, -258, -259, -37, -38, -39, -8],
// authenticatorAttachment: "cross-platform",
authenticatorRequireResidentKey: false,
authenticatorUserVerification: "discouraged",
});
app.use((req, res, next) => {
const { ASN, country } = getIpInfo(req.ip);
res.locals.ASN = ASN;
res.locals.country = country;
next();
});
app.use((req, res, next) => {
const startTime = performance.now();
console.log(
"=>",
new Date().toISOString(),
req.ip,
res.locals.country.isoCode,
res.locals.ASN.autonomousSystemNumber,
req.path
);
res.on("finish", () => {
console.log(
"<=",
new Date().toISOString(),
req.ip,
res.locals.country.isoCode,
res.locals.ASN.autonomousSystemNumber,
req.path,
res.statusCode,
`${(performance.now() - startTime).toFixed(0)}ms`
);
});
next();
});
app.use((req, res, next) => {
if (BLACKLIST_UA && req.headers["user-agent"]?.match(new RegExp(`(${BLACKLIST_UA})`, "i")))
return;
if (WHITELIST_COUNTRY && !WHITELIST_COUNTRY.split("|").includes(res.locals.country.isoCode))
return;
next();
});
app.use(express.json());
app.use(cookieParser());
app.use(
rateLimit({
max: 600, // 600 requests per IP address (per node.js process)
windowMs: 60 * 1000, // per 1 minute
})
);
app.get(/[^\/]+\.[^\/]+$/, express.static("./static", { maxAge: 1000 * 60 * 60 * 24 }));
app.delete("/", (req, res) => {
if (
ENABLE_FIDO2 &&
!fs
.readdirSync("session")
.map((e) => e.replace(".json", ""))
.includes(req.cookies.session)
) {
return res.status(403);
}
fs.copyFileSync("data/latest.json", `data/${Date.now()}.json`);
fs.writeFileSync(
"data/latest.json",
JSON.stringify(
JSON.parse(fs.readFileSync("data/latest.json")).filter((e) => e.name !== req.body.name),
null,
2
)
);
return res.sendStatus(204);
});
app.post("/", (req, res) => {
if (
ENABLE_FIDO2 &&
!fs
.readdirSync("session")
.map((e) => e.replace(".json", ""))
.includes(req.cookies.session)
) {
return res.status(403);
}
if (!req.body.name || !req.body.otp) return res.sendStatus(400);
if (!req.body.name.match(/[a-zA-Z][a-zA-Z0-9]+/)) return res.sendStatus(400);
if (!req.body.otp.match(/[a-zA-Z0-9]{16,}/)) return res.sendStatus(400);
fs.copyFileSync("data/latest.json", `data/${Date.now()}.json`);
fs.writeFileSync(
"data/latest.json",
JSON.stringify(JSON.parse(fs.readFileSync("data/latest.json")).concat(req.body), null, 2)
);
return res.sendStatus(204);
});
app.get("/login", rateLimit({ max: 5, windowMs: 60 * 1000 }), getLogin);
app.post("/login", rateLimit({ max: 5, windowMs: 60 * 1000 }), postLogin);
app.get("/register", rateLimit({ max: 5, windowMs: 60 * 1000 }), getRegister);
app.post("/register", rateLimit({ max: 5, windowMs: 60 * 1000 }), postRegister);
app.get("/reg", async (req, res) => {
if (ENABLE_FIDO2 && ALLOW_REGISTER)
return res.render("register", {
modifiedJS: Math.floor(fs.statSync("./static/register.js").mtimeMs).toString(36),
});
return res.status(403).send("Registration disabled");
});
app.get("/", async (req, res) => {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "GET, OPTIONS");
res.set("Referrer-Policy", "no-referrer");
res.set("X-Content-Type-Options", "nosniff");
res.set(
"Content-Security-Policy",
[
"default-src 'self'",
"base-uri 'none'",
"frame-ancestors 'none'",
"block-all-mixed-content",
].join("; ")
);
if (
ENABLE_FIDO2 &&
!fs
.readdirSync("session")
.map((e) => e.replace(".json", ""))
.includes(req.cookies.session)
) {
return res.render("login", {
modifiedJS: Math.floor(fs.statSync("./static/login.js").mtimeMs).toString(36),
ALLOW_REGISTER,
});
}
if (req.headers.accept?.toLowerCase() === "text/event-stream") {
res.set({
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
});
res.flushHeaders();
res.write("retry: 1000\n\n");
while (true) {
res.write(
`data: ${JSON.stringify({
nextUpdate:
(Math.floor(Math.round(new Date().getTime() / 1000.0) / 30) + 1) * 30 * 1000 -
new Date().getTime(),
list: JSON.parse(fs.readFileSync("data/latest.json")).map(({ name, otp }) => ({
name,
otp: getOtp(otp),
})),
})}\n\n`
);
await new Promise((resolve) =>
setTimeout(
resolve,
(Math.floor(Math.round(new Date().getTime() / 1000.0) / 30) + 1) * 30 * 1000 -
new Date().getTime()
)
);
}
}
return res.render("index", {
mtimeJS: Math.floor(fs.statSync("./static/index.js").mtimeMs).toString(36),
mtimeCSS: Math.floor(fs.statSync("./static/style.css").mtimeMs).toString(36),
list: JSON.parse(fs.readFileSync("data/latest.json")).map(({ name, otp }) => ({
name,
otp: "",
})),
});
});
app.listen(SERVER_PORT, SERVER_ADDR, () =>
console.log(`Media server listening on ${SERVER_ADDR}:${SERVER_PORT}`)
);
const getOtp = (secret) => {
const timeBuffer = Buffer.alloc(8, 0);
timeBuffer.writeUInt32BE(Math.floor(Math.round(new Date().getTime() / 1000.0) / 30), 4);
const bits = secret
.toUpperCase()
.split("")
.map((c) => "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(c).toString(2).padStart(5, "0"))
.join("");
const secretBuffer = Buffer.alloc(Math.ceil(secret.length / 16) * 10);
for (let i = 0; i <= Math.floor(bits.length / 8); i++) {
secretBuffer[i] = parseInt(bits.substring(i * 8, (i + 1) * 8), 2);
}
const hmac = crypto.createHmac("sha1", secretBuffer).update(timeBuffer).digest("hex");
const offset = parseInt(hmac.substring(hmac.length - 1), 16) & 0x7fffffff;
const otp = (parseInt(hmac.substring(offset * 2, offset * 2 + 8), 16) & 0x7fffffff).toString();
return otp.length > 6 ? otp.substring(otp.length - 6) : otp.padStart(6, "0");
};