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 ] ,
[ "--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 ) {
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 ,
} ,
saveUninitialized : false ,
store : auth . sessionStore ,
resave : false ,
secret : auth . cookie _secret ,
proxy : ! opts . DANGER _ADMIN
}
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 ) {
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 ) {
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 ) {
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 ) ;