code-server/src/node/http.ts

249 lines
7.7 KiB
TypeScript
Raw Normal View History

import { field, logger } from "@coder/logger"
2020-10-21 06:05:58 +07:00
import * as express from "express"
import * as expressCore from "express-serve-static-core"
2020-02-05 02:27:46 +07:00
import * as http from "http"
import * as net from "net"
2020-10-21 06:05:58 +07:00
import qs from "qs"
2020-02-05 02:27:46 +07:00
import safeCompare from "safe-compare"
import { HttpCode, HttpError } from "../common/http"
2020-10-21 06:05:58 +07:00
import { normalize, Options } from "../common/util"
import { AuthType } from "./cli"
2020-10-21 06:05:58 +07:00
import { commit, rootPath } from "./constants"
import { hash } from "./util"
2020-02-05 02:27:46 +07:00
/**
2020-10-21 06:05:58 +07:00
* Replace common variable strings in HTML templates.
2020-02-05 02:27:46 +07:00
*/
2020-10-21 06:05:58 +07:00
export const replaceTemplates = <T extends object>(
req: express.Request,
content: string,
extraOpts?: Omit<T, "base" | "csStaticBase" | "logLevel">,
): string => {
const base = relativeRoot(req)
const options: Options = {
base,
csStaticBase: base + "/static/" + commit + rootPath,
logLevel: logger.level,
...extraOpts,
}
return content
.replace(/{{TO}}/g, (typeof req.query.to === "string" && req.query.to) || "/")
.replace(/{{BASE}}/g, options.base)
.replace(/{{CS_STATIC_BASE}}/g, options.csStaticBase)
.replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`)
2020-02-05 02:27:46 +07:00
}
/**
2020-10-21 06:05:58 +07:00
* Throw an error if not authorized.
2020-02-05 02:27:46 +07:00
*/
2020-10-21 06:05:58 +07:00
export const ensureAuthenticated = (req: express.Request): void => {
if (!authenticated(req)) {
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
}
2020-02-05 02:27:46 +07:00
}
2020-10-21 06:05:58 +07:00
/**
* Return true if authenticated via cookies.
*/
export const authenticated = (req: express.Request): boolean => {
switch (req.args.auth) {
case AuthType.None:
return true
case AuthType.Password:
// The password is stored in the cookie after being hashed.
return req.args.password && req.cookies.key && safeCompare(req.cookies.key, hash(req.args.password))
default:
throw new Error(`Unsupported auth type ${req.args.auth}`)
}
2020-02-05 05:55:27 +07:00
}
2020-10-21 06:05:58 +07:00
/**
* Get the relative path that will get us to the root of the page. For each
* slash we need to go up a directory. For example:
* / => .
* /foo => .
* /foo/ => ./..
* /foo/bar => ./..
* /foo/bar/ => ./../..
*/
export const relativeRoot = (req: express.Request): string => {
const depth = (req.originalUrl.split("?", 1)[0].match(/\//g) || []).length
return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : ""))
2020-02-15 04:57:51 +07:00
}
2020-10-21 06:05:58 +07:00
/**
* Redirect relatively to `/${to}`. Query variables will be preserved.
* `override` will merge with the existing query (use `undefined` to unset).
*/
export const redirect = (
req: express.Request,
res: express.Response,
to: string,
override: expressCore.Query = {},
): void => {
const query = Object.assign({}, req.query, override)
Object.keys(override).forEach((key) => {
if (typeof override[key] === "undefined") {
delete query[key]
}
})
const relativePath = normalize(`${relativeRoot(req)}/${to}`, true)
const queryString = qs.stringify(query)
const redirectPath = `${relativePath}${queryString ? `?${queryString}` : ""}`
logger.debug(`redirecting from ${req.originalUrl} to ${redirectPath}`)
res.redirect(redirectPath)
}
2020-02-05 02:27:46 +07:00
/**
2020-10-21 06:05:58 +07:00
* Get the value that should be used for setting a cookie domain. This will
* allow the user to authenticate only once. This will use the highest level
* domain (e.g. `coder.com` over `test.coder.com` if both are specified).
2020-02-05 02:27:46 +07:00
*/
2020-10-21 06:05:58 +07:00
export const getCookieDomain = (host: string, proxyDomains: string[]): string | undefined => {
const idx = host.lastIndexOf(":")
host = idx !== -1 ? host.substring(0, idx) : host
if (
// Might be blank/missing, so there's nothing more to do.
!host ||
// IP addresses can't have subdomains so there's no value in setting the
// domain for them. Assume anything with a : is ipv6 (valid domain name
// characters are alphanumeric or dashes).
host.includes(":") ||
// Assume anything entirely numbers and dots is ipv4 (currently tlds
// cannot be entirely numbers).
!/[^0-9.]/.test(host) ||
// localhost subdomains don't seem to work at all (browser bug?).
host.endsWith(".localhost") ||
// It might be localhost (or an IP, see above) if it's a proxy and it
// isn't setting the host header to match the access domain.
host === "localhost"
) {
logger.debug("no valid cookie doman", field("host", host))
return undefined
}
proxyDomains.forEach((domain) => {
if (host.endsWith(domain) && domain.length < host.length) {
host = domain
}
})
logger.debug("got cookie doman", field("host", host))
return host ? `Domain=${host}` : undefined
}
declare module "express" {
function Router(options?: express.RouterOptions): express.Router & WithWebsocketMethod
type WebsocketRequestHandler = (
socket: net.Socket,
head: Buffer,
req: express.Request,
next: express.NextFunction,
) => void | Promise<void>
type WebsocketMethod<T> = (route: expressCore.PathParams, ...handlers: WebsocketRequestHandler[]) => T
interface WithWebsocketMethod {
ws: WebsocketMethod<this>
}
}
export const handleUpgrade = (app: express.Express, server: http.Server): void => {
server.on("upgrade", (req, socket, head) => {
socket.on("error", () => socket.destroy())
req.ws = socket
req.head = head
req._ws_handled = false
const res = new http.ServerResponse(req)
res.writeHead = function writeHead(statusCode: number) {
if (statusCode > 200) {
socket.destroy(new Error(`${statusCode}`))
}
2020-10-21 06:05:58 +07:00
return res
}
2020-02-05 02:27:46 +07:00
2020-10-21 06:05:58 +07:00
// Send the request off to be handled by Express.
;(app as any).handle(req, res, () => {
if (!req._ws_handled) {
socket.destroy(new Error("Not found"))
}
})
2020-10-21 06:05:58 +07:00
})
}
2020-02-05 02:27:46 +07:00
2020-10-21 06:05:58 +07:00
/**
* Patch Express routers to handle web sockets and async routes (since we have
* to patch `get` anyway).
*
* Not using express-ws since the ws-wrapped sockets don't work with the proxy
* and wildcards don't work correctly.
*/
function patchRouter(): void {
// Apparently this all works because Router is also the prototype assigned to
// the routers it returns.
// Store these since the original methods will be overridden.
const originalGet = (express.Router as any).get
const originalPost = (express.Router as any).post
// Inject the `ws` method.
;(express.Router as any).ws = function ws(
route: expressCore.PathParams,
...handlers: express.WebsocketRequestHandler[]
) {
originalGet.apply(this, [
route,
...handlers.map((handler) => {
const wrapped: express.Handler = (req, _, next) => {
if ((req as any).ws) {
;(req as any)._ws_handled = true
Promise.resolve(handler((req as any).ws, (req as any).head, req, next)).catch(next)
} else {
next()
}
2020-02-05 02:27:46 +07:00
}
2020-10-21 06:05:58 +07:00
return wrapped
}),
])
return this
}
// Overwrite `get` so we can distinguish between websocket and non-websocket
// routes. While we're at it handle async responses.
;(express.Router as any).get = function get(route: expressCore.PathParams, ...handlers: express.Handler[]) {
originalGet.apply(this, [
route,
...handlers.map((handler) => {
const wrapped: express.Handler = (req, res, next) => {
if (!(req as any).ws) {
Promise.resolve(handler(req, res, next)).catch(next)
} else {
next()
}
}
return wrapped
}),
])
return this
}
// Handle async responses for `post` as well since we're in here anyway.
;(express.Router as any).post = function post(route: expressCore.PathParams, ...handlers: express.Handler[]) {
originalPost.apply(this, [
route,
...handlers.map((handler) => {
const wrapped: express.Handler = (req, res, next) => {
Promise.resolve(handler(req, res, next)).catch(next)
}
return wrapped
}),
])
return this
}
2020-02-05 02:27:46 +07:00
}
2020-10-21 06:05:58 +07:00
// This needs to happen before anything uses the router.
patchRouter()