You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

336 lines
11 KiB

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_addr>", "IP address to listen on.", "0.0.0.0"],
["--port <int>", "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);