import express from 'express'; import http from 'http'; import * as SocketIO from 'socket.io'; import morgan from 'morgan'; import session from 'express-session'; import glob from "glob"; import path from 'path'; import { developer_admin } from "../lib/api.js"; import logging from '../lib/logging.js'; import assert from 'assert'; import * as error_handlers from '../api/_errors.js'; import devtools from '../lib/devtools.js'; import * as acorn from "acorn"; import fs from "fs"; import cors from "cors"; import { base_host } from "../client/config.js"; import { createHttpTerminator } from 'http-terminator'; const app = express(); const http_server = http.createServer(app); const terminator = createHttpTerminator({ server: http_server}); const io = new SocketIO.Server(http_server); const log = logging.create("/services/api.js"); const API_BASE = path.resolve("./api"); const SOCKET_BASE = path.resolve("./socket"); const socket_routes = {}; export const description = "The API service for bando."; export const options = [ ["--host ", "IP address to listen on.", "0.0.0.0"], ["--port ", "port to listen on.", "5001"], ["--DANGER_ADMIN", "Enable DANGEROUS development mode.", developer_admin ? "1" : undefined], ["--debug-headers", "Print out HTTP request and response headers"], ["--cookies-suck", "Check various headers/settings that ruin headers"], ["--log-http", "Enable HTTP request logging.", false], ]; const report_code_error = (error, file_name) => { // svelte uses filename so use that too let error_info = { message: error.message, error_name: error.name, filename: file_name, stack: error.stack, code: fs.readFileSync(file_name).toString() } try { let parse_info = acorn.parse(error_info.code, {ecmaVersion: 2020, sourceType: "module"}); } catch(parse_error) { error_info.line = parse_error.loc.line; error_info.column = parse_error.loc.column; } log.error(error, `Failed to load ${file_name}`); devtools.errors.push(error_info); console.log("ERRORS", devtools.errors); } const dynamic_load = async (pattern, loader) => { const files = glob.sync(pattern); for(let file_name of files) { try { log.info(`Loading module ${file_name}`); let route = await import('../' + file_name); for(let func_name in route) { await loader(file_name, route, func_name); } } catch(error) { report_code_error(error, file_name); } } } const shorten_path = (base, file_name) => { const long = path.resolve(file_name).replace(/\\/gi, "/"); // convert all the windows crap \\ to / for the web return long.slice(base.length).slice(0, -3); // the -3 strips .js } const shutdown = async () => { console.warn("SHUTTING DOWN..."); await terminator.terminate(); console.warn("finished, now exiting."); process.exit(0); } export const main = async (opts) => { const auth = await import("../lib/auth.js"); if(opts.DANGER_ADMIN === "1") { const { media_servers } = await import("../lib/config.js"); app.use(cors({origin: media_servers})); // this is necessary to deal with Node 18 defaulting to IPv6 for "localhost" app.use((req, res, next) => { const full_host = `${req.protocol}://${req.get('host')}`; if(full_host !== base_host) { const redirect_to = new URL(req.url, base_host); log.error(`Your client/config.js:base_host is ${base_host} but you are accessing the site at ${full_host}. Redirecting to ${redirect_to} so cookies will function!`); res.redirect(redirect_to); } else { next(); } }) } if(opts.debugHeaders) { morgan.token('headers', (req, res) => { const req_h = JSON.stringify(req.headers, null, 4); const res_h = JSON.stringify(res.getHeaders(), null, 4); return `--- REQUEST\n${req_h}\n--- RESPONSE\n${res_h}`; }); app.use(morgan('\n#### :date[web] :method :status :url\n:headers')); } else if(opts.logHttp) { app.use(morgan('combined')); } // this is needed because the json parser doesn't // keep the original body but it's needed for signatures const raw_body = (req, res, buf, encoding) => { if(buf && buf.length) { req.raw_body = buf.toString(encoding || 'utf8'); } } // parse application/x-www-form-urlencoded app.use(express.urlencoded({ extended: false, verify: raw_body })); // parse application/json app.use(express.json({verify: raw_body})); assert(auth.cookie_domain !== undefined, "The cookie domain is not set. It's required now."); // you have to tell express to trust the proxy twice app.set('trust proxy', true); const session_config = { cookie: { maxAge: 86400000, domain: auth.cookie_domain, httpOnly: false, sameSite: "strict", secure: opts.DANGER_ADMIN !== "1", }, saveUninitialized: false, store: auth.sessionStore, resave: false, secret: auth.cookie_secret, proxy: opts.DANGER_ADMIN !== "1" } const session_handler = session(session_config); const cookie_debugger = (req, res, next) => { session_handler(req, res, () => { const forward_proto = req.headers['x-forwarded-proto']; const secure_required = req.session && req.session.cookie.secure; const trust_proxy = session_config.proxy; const request_secure = req.secure; const encrypted = req.connection && req.connection.encrypted; if(!req.session) { log.warn("No session found, results may be different than if there is a session."); } // this replicates the logic in express-session if(secure_required) { if(encrypted) { log.info("cookie.secure=true, and connection is encrypted, so cookies should work"); } else if(trust_proxy === false) { log.error("cookie.secure=true, but proxy=false, and connection NOT encrypted, so express-session will refuese to set cookies"); } else if(trust_proxy !== true) { // remember, this weirdo logic is from expresss-session if(request_secure === true) { log.info("no explicit trust set (no proxy: set), but req.secure is set by express, so cookies should work"); } else { log.error("trust proxy not explicitly true or false (no proxy: set), but express says the req.secure=false, cookies will NOT BE SET"); } } else { // read the proto from x-forwarded-proto header let header = forward_proto || ''; let index = header.indexOf(','); let proto = index !== -1 ? header.substr(0, index).toLowerCase().trim() : header.toLowerCase().trim() if(proto === "https") { log.info("cookie.secure=true, the connection is not encrypted, and x-forwarded-proto is https, so cookies should be set"); } else { log.error("cookie.secure=true, the connection is not encrypted, and x-forwarded-proto is NOT https! Your cookies will fail."); } } } else { log.warn("cookie.secure is false so cookies should work, but are not secure"); } next(); }); } // and also say trust the proxy here too if(opts.cookiesSuck) { app.use(cookie_debugger) } else { app.use(session_handler); } auth.init(app); app.use(express.static('public')); app.use("/media", express.static('media')); if(opts.DANGER_ADMIN === "1") { log.warn("!!!!!! Exposing client/bando/demos to the network because you set DANGER_ADMIN."); app.use("/bando/demos/", express.static("admin/bando/demos")); } await dynamic_load("./api/**/[A-Za-z]*.js", (file_name, route, func_name) => { try { // don't load non-js files if(path.extname(file_name) !== ".js") { log.warn(`File ${file_name} not loaded since it doesn't end in .js`); return; } const short = shorten_path(API_BASE, file_name); let route_path = `/api${short}`; log.debug(`Loading ${file_name}:${func_name} into route ${route_path}`); // TODO: maybe wrap this with a try/catch for better error reporting, but // the problem is we have to copy the authenticated and login setting over // to the wrapping function, and in production it might not be very helpful const func = route[func_name]; if(func_name == 'del') { log.debug(`Renaming del method to delete to compensate for the JavaScript delete keyword in ${file_name}`); func_name = 'delete'; } let func_info = { name: func_name, code: func.toString(), authenticated: func.authenticated === true, login: func.login === true }; if(opts.DANGER_ADMIN === "1") { if(devtools.api[route_path] == undefined) { // new thing so set up its data initially devtools.api[route_path] = { name: route_path, functions: [func_info] }; } else { // seen this so just add to the functions list devtools.api[route_path].functions.push(func_info); } } if(func.authenticated) { app[func_name](route_path, auth.required(), func); } else if(func.login) { app[func_name](route_path, auth.login(), func); } else { app[func_name](route_path, func); } } catch(error) { log.error(error, `Failed to run ${file_name}:${func_name}`); } }) await dynamic_load("./socket/**/[A-Za-z]*.js", (file_name, route, func_name) => { // don't load non-js files if(path.extname(file_name) !== ".js") { log.warn(`File ${file_name} not loaded since it doesn't end in .js`); return; } const route_path = shorten_path(SOCKET_BASE, file_name); const func = route[func_name]; const target_name = `${route_path}/${func_name}`; log.debug(`Adding route ${target_name} from file ${file_name} which is ${route_path}.`); socket_routes[target_name] = func if(opts.DANGER_ADMIN === "1") { devtools.sockets[target_name] = { route_path, target_name, file_name, code: func.toString() } } }) app.use(error_handlers.exception); app.use(error_handlers.missing); io.on('connection', (socket) => { log.debug('a user connected'); socket.on('disconnect', () => { log.debug('user disconnected'); }) for(let target_name in socket_routes) { const func = socket_routes[target_name]; log.debug(`Adding ${target_name} to socket. Authenticated is ${func.authenticated}.`); if(func.authenticated) { socket.on(target_name, async (data) => { if(await auth.socket(socket)) { return func(io, socket, data); } else { log.error(`Authentication failure for ${target_name}`, socket.user, data); } }); } else { socket.on(target_name, async (data) => { return func(io, socket, data); }); } } }) http_server.listen(opts.port, opts.host, () => { log.info(`listening on ${opts.host}:${opts.port}`); }) } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);