http.ts 7.7 KB
Newer Older
A
Asher 已提交
1
import { field, logger } from "@coder/logger"
A
Asher 已提交
2 3
import * as express from "express"
import * as expressCore from "express-serve-static-core"
A
Asher 已提交
4 5
import * as http from "http"
import * as net from "net"
A
Asher 已提交
6
import qs from "qs"
A
Asher 已提交
7 8
import safeCompare from "safe-compare"
import { HttpCode, HttpError } from "../common/http"
A
Asher 已提交
9
import { normalize, Options } from "../common/util"
A
Asher 已提交
10
import { AuthType } from "./cli"
A
Asher 已提交
11 12
import { commit, rootPath } from "./constants"
import { hash } from "./util"
A
Asher 已提交
13 14

/**
A
Asher 已提交
15
 * Replace common variable strings in HTML templates.
A
Asher 已提交
16
 */
A
Asher 已提交
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
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)}'`)
A
Asher 已提交
34 35 36
}

/**
A
Asher 已提交
37
 * Throw an error if not authorized.
A
Asher 已提交
38
 */
A
Asher 已提交
39 40 41
export const ensureAuthenticated = (req: express.Request): void => {
  if (!authenticated(req)) {
    throw new HttpError("Unauthorized", HttpCode.Unauthorized)
42
  }
A
Asher 已提交
43 44
}

A
Asher 已提交
45 46 47 48 49 50 51 52 53 54 55 56 57
/**
 * 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}`)
  }
A
Asher 已提交
58 59
}

A
Asher 已提交
60 61 62 63 64 65 66 67 68 69 70 71
/**
 * 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) : ""))
A
Asher 已提交
72 73
}

A
Asher 已提交
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
/**
 * 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)
96 97
}

A
Asher 已提交
98
/**
A
Asher 已提交
99 100 101
 * 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).
A
Asher 已提交
102
 */
A
Asher 已提交
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164
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}`))
A
Asher 已提交
165
      }
A
Asher 已提交
166 167
      return res
    }
A
Asher 已提交
168

A
Asher 已提交
169 170 171 172
    // 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"))
A
Asher 已提交
173 174
      }
    })
A
Asher 已提交
175 176
  })
}
A
Asher 已提交
177

A
Asher 已提交
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206
/**
 * 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()
A
Anmol Sethi 已提交
207
          }
A
Asher 已提交
208
        }
A
Asher 已提交
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243
        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
A
Asher 已提交
244
  }
A
Asher 已提交
245
}
A
Asher 已提交
246 247 248

// This needs to happen before anything uses the router.
patchRouter()