@webda/shell
Version:
Deploy a Webda app or configure it
354 lines • 12.7 kB
JavaScript
import { HttpContext, ResourceService, WaitFor, WaitLinearDelay, Core as Webda, WebdaError } from "@webda/core";
import { serialize as cookieSerialize } from "cookie";
import * as http from "http";
import { createChecker } from "is-in-subnet";
export var ServerStatus;
(function (ServerStatus) {
ServerStatus["Stopped"] = "STOPPED";
ServerStatus["Stopping"] = "STOPPING";
ServerStatus["Starting"] = "STARTING";
ServerStatus["Started"] = "STARTED";
})(ServerStatus || (ServerStatus = {}));
export class WebdaServer extends Webda {
constructor() {
super(...arguments);
this.serverStatus = ServerStatus.Stopped;
}
/**
* Toggle DevMode
*
* In DevMode CORS is allowed
* @param devMode
*/
setDevMode(devMode) {
this.devMode = devMode;
if (devMode) {
this.output("Dev mode activated : wildcard CORS enabled");
}
}
/**
* Return true if devMode is enabled
* @returns
*/
isDebug() {
return this.devMode ?? false;
}
output(...args) {
this.log("INFO", ...args);
}
/**
* Check if a proxy is a trusted proxy
* @param ip
* @returns
*/
isProxyTrusted(ip) {
// ipv4 mapped to v6
return this.subnetChecker(ip);
}
/**
* Return a Context object based on a request
* @param req to initiate object from
* @param res to add for body
* @returns
*/
async getContextFromRequest(req, res) {
// Wait for Webda to be ready
await this.init();
// Handle reverse proxy
let vhost = req.headers.host.match(/:/g)
? req.headers.host.slice(0, req.headers.host.indexOf(":"))
: req.headers.host;
if ((req.headers["x-forwarded-for"] ||
req.headers["x-forwarded-host"] ||
req.headers["x-forwarded-proto"] ||
req.headers["x-forwarded-port"]) &&
!this.isProxyTrusted(req.socket.remoteAddress)) {
// Do not even let the query go through
this.log("WARN", `X-Forwarded-* headers set from an unknown source: ${req.socket.remoteAddress}`);
res.writeHead(400);
return;
}
// Might want to add some whitelisting
if (req.headers["x-forwarded-host"] !== undefined) {
vhost = req.headers["x-forwarded-host"];
}
let protocol = "http";
if (req.headers["x-forwarded-proto"] !== undefined) {
protocol = req.headers["x-forwarded-proto"];
}
let method = req.method;
let port;
if (req.socket && req.socket.address()) {
port = req.socket.address().port;
}
if (req.headers["x-forwarded-port"] !== undefined) {
port = parseInt(req.headers["x-forwarded-port"]);
}
else if (req.headers["x-forwarded-proto"] !== undefined) {
// GCP send a proto without port so fallback on default port
port = protocol === "http" ? 80 : 443;
}
let httpContext = new HttpContext(vhost, method, req.url, protocol, port, req.headers);
httpContext.setClientIp(httpContext.getUniqueHeader("x-forwarded-for", req.socket.remoteAddress));
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
if (["PUT", "PATCH", "POST", "DELETE"].includes(method)) {
httpContext.setBody(req);
}
return this.newWebContext(httpContext, res, true);
}
/**
* Manage the request
*
* @param req
* @param res
* @param next
*/
async handleRequest(req, res) {
let ctx;
let emitResult = false;
try {
res.on("error", this.log.bind(this, "ERROR"));
ctx = await this.getContextFromRequest(req, res);
// @ts-ignore
req.webdaContext = ctx;
}
catch (err) {
this.log("ERROR", err);
}
// If no context, we are in error
if (!ctx) {
if (res.statusCode < 400) {
res.writeHead(500);
}
res.end();
return;
}
try {
let httpContext = ctx.getHttpContext();
if (!this.updateContextWithRoute(ctx) && httpContext.getMethod() !== "OPTIONS") {
// Static served should not be reachable via XHR
if (httpContext.getMethod() !== "GET" || !this.resourceService) {
ctx.writeHead(404);
return;
}
// Try to serve static resource
await ctx.init();
ctx.getParameters()["resource"] = ctx.getHttpContext().getUrl().substring(1);
await this.resourceService._serve(ctx);
return;
}
await ctx.init();
const origin = (req.headers.Origin || req.headers.origin);
// Set predefined headers for CORS
if (this.devMode || !origin || (await this.checkCORSRequest(ctx))) {
ctx.setHeader("Access-Control-Allow-Origin", origin);
}
else {
throw new WebdaError.Unauthorized(`CORS denied from ${origin}`);
}
// Verify if request is authorized
if (!(await this.checkRequest(ctx))) {
this.log("WARN", "Request refused");
throw new WebdaError.Forbidden("Request refused");
}
if (httpContext.getProtocol() === "https:") {
// Add the HSTS header
ctx.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
}
// Add correct headers for X-scripting
if (req.headers["x-forwarded-server"] === undefined) {
if (this.devMode && req.headers["origin"]) {
ctx.setHeader("Access-Control-Allow-Origin", req.headers["origin"]);
}
}
// Handle OPTIONS
if (req.method === "OPTIONS") {
let routes = this.router.getRouteMethodsFromUrl(httpContext.getRelativeUri());
// OPTIONS on unknown route should return 404
if (routes.length === 0) {
ctx.writeHead(404);
return;
}
routes.push("OPTIONS");
ctx.setHeader("Access-Control-Allow-Credentials", "true");
ctx.setHeader("Access-Control-Allow-Headers", req.headers["access-control-request-headers"] || "content-type");
ctx.setHeader("Access-Control-Allow-Methods", routes.join(","));
ctx.setHeader("Access-Control-Max-Age", 86400);
ctx.setHeader("Allow", routes.join(","));
ctx.writeHead(200);
return;
}
await this.emitSync("Webda.Request", { context: ctx });
ctx.setHeader("Access-Control-Allow-Credentials", "true");
emitResult = true;
this.log("DEBUG", "Execute", ctx.getHttpContext().getMethod(), ctx.getHttpContext().getUrl());
await ctx.execute();
await this.emitSync("Webda.Result", { context: ctx });
}
catch (err) {
err = typeof err === "number" ? new WebdaError.HttpError("Wrapped", err) : err;
if (err instanceof WebdaError.HttpError) {
ctx.statusCode = err.getResponseCode();
// Handle redirect
if (err instanceof WebdaError.Redirect) {
ctx.setHeader("Location", err.location);
}
this.log("TRACE", `${err.getResponseCode()}: ${err.message}`);
}
else {
ctx.statusCode = 500;
}
// If we have a context, we can send the error
emitResult && (await this.emitSync("Webda.Result", { context: ctx }));
if (ctx.statusCode >= 500) {
this.log("ERROR", err);
}
}
finally {
await ctx.end();
}
}
/**
* @override
*/
flushHeaders(ctx) {
if (ctx.hasFlushedHeaders()) {
return;
}
ctx.setFlushedHeaders(true);
const res = ctx.getStream();
const headers = ctx.getResponseHeaders();
const cookies = ctx.getResponseCookies();
try {
for (let i in cookies) {
res.setHeader("Set-Cookie", cookieSerialize(cookies[i].name, cookies[i].value, cookies[i].options));
}
res.writeHead(ctx.statusCode, headers);
}
catch (err) {
this.log("ERROR", err);
}
}
/**
* @override
*/
flush(ctx) {
const res = ctx._stream;
const body = ctx.getResponseBody();
if (body !== undefined && body) {
res.write(body);
}
}
/**
* @override
*/
async init() {
var _a;
// Avoid reinit everytime
if (this._init) {
return this._init;
}
await super.init();
(_a = this.getGlobalParams()).trustedProxies ?? (_a.trustedProxies = "127.0.0.1");
if (typeof this.getGlobalParams().trustedProxies === "string") {
this.getGlobalParams().trustedProxies = this.getGlobalParams().trustedProxies.split(",");
}
this.subnetChecker = createChecker(this.getGlobalParams().trustedProxies.map(n => (n.indexOf("/") < 0 ? `${n.trim()}/32` : n.trim())));
if (this.getGlobalParams().website && this.getGlobalParams().website.path && !this.resourceService) {
this.resourceService = await new ResourceService(this, "websiteResource", {
folder: this.getAppPath(this.getGlobalParams().website.path)
})
.resolve()
.init();
}
}
/**
* Start listening to serve request
*
* @param port to listen to
* @param bind address to bind
*/
async serve(port = 18080, bind = undefined) {
this.serverStatus = ServerStatus.Starting;
try {
this.http = http
.createServer(async (req, res) => {
this.handleRequest(req, res).finally(() => {
res.end();
});
})
.listen(port, bind);
process.on("SIGINT", this.onSIGINT.bind(this));
this.http.on("close", () => {
this.serverStatus = ServerStatus.Stopped;
});
this.http.on("error", err => {
this.log("ERROR", err.message);
this.serverStatus = ServerStatus.Stopped;
});
this.emit("Webda.Init.Http", this.http);
this.logger.logTitle(`Server running at http://0.0.0.0:${port}`);
this.serverStatus = ServerStatus.Started;
}
catch (err) {
this.log("ERROR", err);
this.serverStatus = ServerStatus.Stopped;
throw err;
}
}
/**
* Close server and exit
*/
onSIGINT() {
if (this.http) {
this.http.close();
}
}
/**
* Get server status
*/
getServerStatus() {
return this.serverStatus;
}
/**
* Wait for the server to be in a desired state
*
* @param status to wait for
* @param timeout max number of ms to wait for
*/
async waitForStatus(status, timeout = 60000) {
return WaitFor(async (resolve) => {
if (this.getServerStatus() === status) {
resolve();
return true;
}
}, timeout / 1000, "Waiting for server status", undefined, WaitLinearDelay(1000));
}
async stopHttp() {
if (this.http && this.serverStatus === ServerStatus.Starting) {
await this.waitForStatus(ServerStatus.Started);
}
this.serverStatus = ServerStatus.Stopping;
if (this.http) {
await new Promise((resolve, reject) => {
this.http.close(err => {
if (err) {
reject(err);
}
else {
resolve();
}
});
});
}
this.serverStatus = ServerStatus.Stopped;
this.http = undefined;
}
/**
* Stop the http server
*/
async stop() {
await Promise.all([super.stop(), this.stopHttp()]);
}
}
//# sourceMappingURL=http.js.map