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
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);
|
|
|