aboutsummaryrefslogtreecommitdiff
path: root/src/server.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/server.js')
-rw-r--r--src/server.js327
1 files changed, 327 insertions, 0 deletions
diff --git a/src/server.js b/src/server.js
new file mode 100644
index 0000000..a833dad
--- /dev/null
+++ b/src/server.js
@@ -0,0 +1,327 @@
+const { access, constants, readFile, writeFile } = require("node:fs");
+const { join } = require("node:path");
+const { auth } = require("express-openid-connect");
+
+const internals = {
+ kLogoutURL: "/logout",
+ kLoginURL: "/auth/login",
+ kClaimURL: "/auth/claim-wiki",
+ kSuccessfulLoginURL: "/auth/success",
+ buildBaseURL({ url, wiki_domain, security_useHttps }) {
+ let host = wiki_domain || url;
+
+ if (host.includes("//")) {
+ return `${host}`;
+ }
+
+ const protocol = security_useHttps ? "https" : "http";
+ return `${protocol}://${host}`;
+ },
+};
+
+/**
+ * Creates a security handler for the wiki.
+ * @param {(...args: any[]) => void} log a logger function pre-configured to
+ * only log if debug is on.
+ * @param {(...args: any[]) => void} loga a logger function that always logs.
+ * @argv {Object} argv The wiki configuration.
+ */
+module.exports = function createHandler(
+ log,
+ loga,
+ {
+ id: idFile,
+ url,
+ wiki_domain,
+ admin,
+ security_useHttps,
+ oidc_claimOnFirstLogin,
+ oidc_clientID,
+ oidc_clientSecret,
+ oidc_idpLogout,
+ oidc_issuerBaseURL,
+ oidc_issuerName,
+ oidc_secret,
+ oidc_logoutRedirectURL,
+ restricted,
+ restrictedDomains,
+ restrictedUsers,
+ },
+) {
+ let owner = null;
+ const security = {
+ /**
+ * Reads the owner file to determine the owner.
+ */
+ retrieveOwner(callback) {
+ access(idFile, constants.F_OK, (error) => {
+ if (error) {
+ owner = null;
+ return callback();
+ }
+ readFile(idFile, "utf8", (error, data) => {
+ if (error) {
+ owner = null;
+ return callback(error);
+ }
+ owner = JSON.parse(data);
+ callback();
+ });
+ });
+ },
+
+ /**
+ * Reads the owner file to determine the owner.
+ */
+ getOwner() {
+ return owner?.name || "";
+ },
+
+ /**
+ * Sets a new owner.
+ */
+ setOwner(newOwner, callback) {
+ access(idFile, constants.F_OK, (error) => {
+ if (!error) {
+ return callback(new Error("Site already claimed"));
+ }
+
+ writeFile(idFile, JSON.stringify(newOwner), (error) => {
+ if (error) {
+ return callback(
+ new Error("Could not write owner to file."),
+ );
+ }
+ loga(`Claiming wiki ${url} for ${newOwner.name}`);
+ owner = newOwner;
+ callback();
+ });
+ });
+ },
+
+ /**
+ * Gets the current authenticated user.
+ */
+ getUser(req) {
+ return req.oidc?.user || "";
+ },
+
+ /**
+ * Checks if the current authenticated user is authorized.
+ */
+ isAuthorized(req) {
+ if (owner === null) {
+ loga("isAuthorized: Site not claimed");
+ return true;
+ }
+
+ const preferredUsername = req.oidc.user?.preferred_username;
+ return preferredUsername && preferredUsername === owner.username;
+ },
+
+ /**
+ * Checks if the current authenticated user is an admin.
+ */
+ isAdmin(req) {
+ if (!admin) {
+ return false;
+ }
+ if (!req.oidc?.user) {
+ return false;
+ }
+
+ const preferredUsername = req.oidc.user?.preferred_username;
+ return preferredUsername && preferredUsername === admin;
+ },
+
+ /**
+ * Defines security routes for the wiki.
+ */
+ defineRoutes(app, _, updateOwner) {
+ const viewsPath = join(__dirname, "..", "views");
+
+ const config = {
+ authRequired: false,
+ idpLogout: !!oidc_idpLogout,
+ baseURL: internals.buildBaseURL({
+ url,
+ wiki_domain,
+ security_useHttps,
+ }),
+ clientID: oidc_clientID,
+ clientSecret: oidc_clientSecret,
+ clientAuthMethod: "client_secret_post",
+ issuerBaseURL: oidc_issuerBaseURL,
+ secret: oidc_secret,
+ enableTelemetry: false,
+ routes: {
+ login: false,
+ logout: false,
+ callback: "/auth/oidc/callback",
+ },
+ };
+
+ app.use(auth(config));
+
+ // Starts the OIDC login process.
+ app.get("/auth/oidc", (_, res) =>
+ res.oidc.login({
+ returnTo: internals.kSuccessfulLoginURL,
+ }),
+ );
+
+ // Displays the login dialog.
+ app.get(internals.kLoginURL, (_, res) => {
+ res.render(join(viewsPath, "login.html"), {
+ issuerName: oidc_issuerName || "SSO",
+ wikiName: wiki_domain || url || "wiki",
+ });
+ });
+
+ // Shown after a successful login.
+ app.get(internals.kSuccessfulLoginURL, (req, res) => {
+ if (!req.oidc.isAuthenticated()) {
+ return res.redirect(internals.kLoginURL);
+ }
+
+ const username =
+ req.oidc.user.preferred_username ||
+ req.oidc.user.name ||
+ "User";
+ res.render(join(viewsPath, "success.html"), { username });
+ });
+
+ // Claims a wiki.
+ app.get(internals.kClaimURL, (req, res) => {
+ if (!req.oidc.isAuthenticated()) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+
+ const user = req.oidc.user;
+ const newOwner = {
+ name: user.name || user.preferred_username || user.email,
+ username: user.preferred_username || user.email,
+ };
+
+ security.setOwner(newOwner, (error) => {
+ if (error) {
+ loga(`Failed to claim wiki: ${error.message}`);
+ return res.status(400).json({ error: error.message });
+ }
+ log(`Wiki claimed by ${newOwner.name}`);
+ updateOwner(newOwner.name);
+ res.json({ success: true, owner: newOwner.name });
+ });
+ });
+
+ // Settings sent to the client.
+ app.get("/auth/client-settings.json", (req, res) => {
+ const authenticated = req.oidc.isAuthenticated();
+ const settings = {
+ claimOnFirstLogin: !!oidc_claimOnFirstLogin,
+ logoutURL: internals.kLogoutURL,
+ loginURL: internals.kLoginURL,
+ claimURL: internals.kClaimURL,
+ wikiHost: wiki_domain,
+ useHttps: security_useHttps,
+ owner: owner ? owner.name : null,
+ isOwner: authenticated && security.isAuthorized(req),
+ };
+ res.json(settings);
+ });
+
+ app.get(internals.kLogoutURL, (_, res) => {
+ const returnTo = oidc_logoutRedirectURL || "/";
+ res.oidc.logout({
+ returnTo,
+ });
+ });
+
+ if (restricted) {
+ const allowedToView = (req) => {
+ if (!req.oidc?.user) {
+ return false;
+ }
+
+ const user = req.oidc.user;
+ const username = user.preferred_username || user.email;
+
+ if (restrictedDomains) {
+ const domains = restrictedDomains
+ .split(",")
+ .map((d) => d.trim());
+ const email = user.email || "";
+ const emailDomain = email.split("@")[1];
+
+ if (domains.includes(emailDomain)) {
+ return true;
+ }
+ }
+
+ if (restrictedUsers) {
+ const users = restrictedUsers
+ .split(",")
+ .map((u) => u.trim());
+
+ if (users.includes("*") || users.includes(username)) {
+ return true;
+ }
+ }
+
+ return false;
+ };
+
+ app.all("*", (req, res, next) => {
+ // Site flags are public.
+ if (req.url === "/favicon.png") {
+ return next();
+ }
+ if (!/\.(json|html)$/.test(req.url)) {
+ return next();
+ }
+
+ res.header(
+ "Access-Control-Allow-Origin",
+ req.get("Origin") || "*",
+ );
+ res.header("Access-Control-Allow-Credentials", "true");
+
+ if (
+ (security.isAuthorized(req) && owner !== null) ||
+ allowedToView(req)
+ ) {
+ return next();
+ }
+
+ // Allow HTML views.
+ const htmlMatch = req.url.match(/\/(.*)\.html/);
+ if (htmlMatch) {
+ return res.redirect(`/view/${htmlMatch[1]}`);
+ }
+
+ // Sitemap is public.
+ if (req.url === "/system/sitemap.json") {
+ return res.json(["Login Required"]);
+ }
+
+ // If not allowed, show the login page.
+ const problem =
+ "This is a restricted wiki requires users to login to view pages. You do not have to be the site owner but you do need to login with a participating email address.";
+ const details = `[http://ward.asia.wiki.org/login-to-view.html details]`;
+ res.status(200).json({
+ title: "Login Required",
+ story: [
+ {
+ type: "paragraph",
+ id: "55d44b367ed64875",
+ text: `${problem} ${details}`,
+ },
+ ],
+ });
+ });
+ }
+ },
+ };
+
+ return security;
+};