Make routing base path agnostic

This commit is contained in:
Asher 2020-02-05 17:30:09 -06:00
parent a149c5fc60
commit 4cc181cedc
No known key found for this signature in database
GPG key ID: D63C1EF81242354A
13 changed files with 198 additions and 221 deletions

View file

@ -1,3 +1,4 @@
import { getBasepath } from "hookrouter"
import { Application, ApplicationsResponse, CreateSessionResponse, FilesResponse, RecentResponse } from "../common/api" import { Application, ApplicationsResponse, CreateSessionResponse, FilesResponse, RecentResponse } from "../common/api"
import { ApiEndpoint, HttpCode, HttpError } from "../common/http" import { ApiEndpoint, HttpCode, HttpError } from "../common/http"
@ -18,7 +19,7 @@ export function setAuthed(authed: boolean): void {
* Also set authed to false if the request returns unauthorized. * Also set authed to false if the request returns unauthorized.
*/ */
const tryRequest = async (endpoint: string, options?: RequestInit): Promise<Response> => { const tryRequest = async (endpoint: string, options?: RequestInit): Promise<Response> => {
const response = await fetch("/api" + endpoint + "/", options) const response = await fetch(getBasepath() + "/api" + endpoint + "/", options)
if (response.status === HttpCode.Unauthorized) { if (response.status === HttpCode.Unauthorized) {
setAuthed(false) setAuthed(false)
} }
@ -33,14 +34,9 @@ const tryRequest = async (endpoint: string, options?: RequestInit): Promise<Resp
* Try authenticating. * Try authenticating.
*/ */
export const authenticate = async (body?: AuthBody): Promise<void> => { export const authenticate = async (body?: AuthBody): Promise<void> => {
let formBody: URLSearchParams | undefined
if (body) {
formBody = new URLSearchParams()
formBody.append("password", body.password)
}
const response = await tryRequest(ApiEndpoint.login, { const response = await tryRequest(ApiEndpoint.login, {
method: "POST", method: "POST",
body: formBody, body: JSON.stringify({ ...body, basePath: getBasepath() }),
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8", "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
}, },

View file

@ -1,4 +1,4 @@
import { getBasepath, navigate } from "hookrouter" import { getBasepath, navigate, setBasepath } from "hookrouter"
import * as React from "react" import * as React from "react"
import { Application, isExecutableApplication } from "../common/api" import { Application, isExecutableApplication } from "../common/api"
import { HttpError } from "../common/http" import { HttpError } from "../common/http"
@ -11,25 +11,36 @@ export interface AppProps {
} }
const App: React.FunctionComponent<AppProps> = (props) => { const App: React.FunctionComponent<AppProps> = (props) => {
const [authed, setAuthed] = React.useState<boolean>(!!props.options.authed) const [authed, setAuthed] = React.useState<boolean>(props.options.authed)
const [app, setApp] = React.useState<Application | undefined>(props.options.app) const [app, setApp] = React.useState<Application | undefined>(props.options.app)
const [error, setError] = React.useState<HttpError | Error | string>() const [error, setError] = React.useState<HttpError | Error | string>()
if (typeof window !== "undefined") {
const url = new URL(window.location.origin + window.location.pathname + props.options.basePath)
setBasepath(normalize(url.pathname))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).setAuthed = (a: boolean): void => {
if (authed !== a) {
setAuthed(a)
// TEMP: Remove when no longer auto-loading VS Code.
if (a && !app) {
setApp({
name: "VS Code",
path: "/",
embedPath: "/vscode-embed",
})
}
}
}
}
React.useEffect(() => { React.useEffect(() => {
if (app && !isExecutableApplication(app)) { if (app && !isExecutableApplication(app)) {
navigate(normalize(`${getBasepath()}/${app.path}/`, true)) navigate(normalize(`${getBasepath()}/${app.path}/`, true))
} }
}, [app]) }, [app])
if (typeof window !== "undefined") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).setAuthed = (a: boolean): void => {
if (authed !== a) {
setAuthed(a)
}
}
}
return ( return (
<> <>
{!app || !app.loaded ? ( {!app || !app.loaded ? (
@ -41,7 +52,7 @@ const App: React.FunctionComponent<AppProps> = (props) => {
)} )}
<Modal app={app} setApp={setApp} authed={authed} error={error} setError={setError} /> <Modal app={app} setApp={setApp} authed={authed} error={error} setError={setError} />
{authed && app && app.embedPath ? ( {authed && app && app.embedPath ? (
<iframe id="iframe" src={normalize(`${getBasepath()}/${app.embedPath}/`, true)}></iframe> <iframe id="iframe" src={normalize(`./${app.embedPath}/`, true)}></iframe>
) : ( ) : (
undefined undefined
)} )}

View file

@ -128,7 +128,6 @@ export const Modal: React.FunctionComponent<ModalProps> = (props) => {
<aside className="sidebar-nav"> <aside className="sidebar-nav">
<nav className="links"> <nav className="links">
{props.authed ? ( {props.authed ? (
// TEMP: Remove once we don't auto-load vscode.
<> <>
<button className="link" onClick={(): void => setSection(Section.Recent)}> <button className="link" onClick={(): void => setSection(Section.Recent)}>
Recent Recent

View file

@ -3,17 +3,17 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<!-- <meta http-equiv="Content-Security-Policy" content="font-src 'self'; connect-src 'self'; default-src ws: wss:; style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"> --> <meta http-equiv="Content-Security-Policy" content="font-src 'self' fonts.gstatic.com; connect-src 'self'; default-src ws: wss: 'self'; style-src 'self' fonts.googleapis.com; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;">
<title>code-server</title> <title>code-server</title>
<link rel="icon" href="/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials"> <link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="/static-{{COMMIT}}/src/browser/media/code-server.png" /> <link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans&display=swap" rel="stylesheet" />
<link href="/static-{{COMMIT}}/dist/index.css" rel="stylesheet"> <link href="{{BASE}}/static-{{COMMIT}}/dist/index.css" rel="stylesheet">
<meta id="coder-options" data-settings="{{OPTIONS}}"> <meta id="coder-options" data-settings="{{OPTIONS}}">
</head> </head>
<body> <body>
<div id="root">{{COMPONENT}}</div> <div id="root">{{COMPONENT}}</div>
<script src="/static-{{COMMIT}}/dist/index.js"></script> <script src="{{BASE}}/static-{{COMMIT}}/dist/index.js"></script>
</body> </body>
</html> </html>

View file

@ -22,6 +22,11 @@ export enum SessionError {
Unknown, Unknown,
} }
export interface LoginRequest {
password: string
basePath: string
}
export interface LoginResponse { export interface LoginResponse {
success: boolean success: boolean
} }

View file

@ -3,8 +3,9 @@ import { Application } from "../common/api"
export interface Options { export interface Options {
app?: Application app?: Application
authed?: boolean authed: boolean
logLevel?: number basePath: string
logLevel: number
} }
/** /**

View file

@ -1,17 +1,20 @@
import { field, logger } from "@coder/logger" import { field, logger } from "@coder/logger"
import * as http from "http" import * as http from "http"
import * as net from "net" import * as net from "net"
import * as querystring from "querystring"
import * as ws from "ws" import * as ws from "ws"
import { ApplicationsResponse, ClientMessage, FilesResponse, LoginResponse, ServerMessage } from "../../common/api" import {
ApplicationsResponse,
ClientMessage,
FilesResponse,
LoginRequest,
LoginResponse,
ServerMessage,
} from "../../common/api"
import { ApiEndpoint, HttpCode } from "../../common/http" import { ApiEndpoint, HttpCode } from "../../common/http"
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, PostData } from "../http" import { normalize } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
import { hash } from "../util" import { hash } from "../util"
interface LoginPayload extends PostData {
password?: string | string[]
}
/** /**
* API HTTP provider. * API HTTP provider.
*/ */
@ -22,13 +25,8 @@ export class ApiHttpProvider extends HttpProvider {
super(options) super(options)
} }
public async handleRequest( public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
base: string, switch (route.base) {
_requestPath: string,
_query: querystring.ParsedUrlQuery,
request: http.IncomingMessage
): Promise<HttpResponse | undefined> {
switch (base) {
case ApiEndpoint.login: case ApiEndpoint.login:
if (request.method === "POST") { if (request.method === "POST") {
return this.login(request) return this.login(request)
@ -38,7 +36,7 @@ export class ApiHttpProvider extends HttpProvider {
if (!this.authenticated(request)) { if (!this.authenticated(request)) {
return { code: HttpCode.Unauthorized } return { code: HttpCode.Unauthorized }
} }
switch (base) { switch (route.base) {
case ApiEndpoint.applications: case ApiEndpoint.applications:
return this.applications() return this.applications()
case ApiEndpoint.files: case ApiEndpoint.files:
@ -49,9 +47,7 @@ export class ApiHttpProvider extends HttpProvider {
} }
public async handleWebSocket( public async handleWebSocket(
_base: string, _route: Route,
_requestPath: string,
_query: querystring.ParsedUrlQuery,
request: http.IncomingMessage, request: http.IncomingMessage,
socket: net.Socket, socket: net.Socket,
head: Buffer head: Buffer
@ -93,30 +89,35 @@ export class ApiHttpProvider extends HttpProvider {
* unauthorized. * unauthorized.
*/ */
private async login(request: http.IncomingMessage): Promise<HttpResponse<LoginResponse>> { private async login(request: http.IncomingMessage): Promise<HttpResponse<LoginResponse>> {
const ok = (password: string | true): HttpResponse<LoginResponse> => {
return {
content: {
success: true,
},
cookie: typeof password === "string" ? { key: "key", value: password } : undefined,
}
}
// Already authenticated via cookies? // Already authenticated via cookies?
const providedPassword = this.authenticated(request) const providedPassword = this.authenticated(request)
if (providedPassword) { if (providedPassword) {
return ok(providedPassword) return { code: HttpCode.Ok }
} }
const data = await this.getData(request) const data = await this.getData(request)
const payload: LoginPayload = data ? querystring.parse(data) : {} const payload: LoginRequest = data ? JSON.parse(data) : {}
const password = this.authenticated(request, { const password = this.authenticated(request, {
key: typeof payload.password === "string" ? [hash(payload.password)] : undefined, key: typeof payload.password === "string" ? [hash(payload.password)] : undefined,
}) })
if (password) { if (password) {
return ok(password) return {
content: {
success: true,
},
cookie:
typeof password === "string"
? {
key: "key",
value: password,
path: normalize(payload.basePath),
}
: undefined,
}
} }
// Only log if it was an actual login attempt.
if (payload && payload.password) {
console.error( console.error(
"Failed login attempt", "Failed login attempt",
JSON.stringify({ JSON.stringify({
@ -126,6 +127,7 @@ export class ApiHttpProvider extends HttpProvider {
timestamp: Math.floor(new Date().getTime() / 1000), timestamp: Math.floor(new Date().getTime() / 1000),
}) })
) )
}
return { code: HttpCode.Unauthorized } return { code: HttpCode.Unauthorized }
} }

View file

@ -1,51 +1,54 @@
import { logger } from "@coder/logger" import { logger } from "@coder/logger"
import * as http from "http" import * as http from "http"
import * as querystring from "querystring"
import * as React from "react" import * as React from "react"
import * as ReactDOMServer from "react-dom/server" import * as ReactDOMServer from "react-dom/server"
import App from "../../browser/app" import App from "../../browser/app"
import { Options } from "../../common/util" import { Options } from "../../common/util"
import { HttpProvider, HttpResponse } from "../http" import { HttpProvider, HttpResponse, Route } from "../http"
/** /**
* Top-level and fallback HTTP provider. * Top-level and fallback HTTP provider.
*/ */
export class MainHttpProvider extends HttpProvider { export class MainHttpProvider extends HttpProvider {
public async handleRequest( public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
base: string, switch (route.base) {
requestPath: string, case "/static": {
_query: querystring.ParsedUrlQuery, const response = await this.getResource(this.rootPath, route.requestPath)
request: http.IncomingMessage
): Promise<HttpResponse | undefined> {
if (base === "/static") {
const response = await this.getResource(this.rootPath, requestPath)
if (!this.isDev) { if (!this.isDev) {
response.cache = true response.cache = true
} }
return response return response
} }
case "/": {
const options: Options = {
authed: !!this.authenticated(request),
basePath: this.base(route),
logLevel: logger.level,
}
if (options.authed) {
// TEMP: Auto-load VS Code for now. In future versions we'll need to check // TEMP: Auto-load VS Code for now. In future versions we'll need to check
// the URL for the appropriate application to load, if any. // the URL for the appropriate application to load, if any.
const app = { options.app = {
name: "VS Code", name: "VS Code",
path: "/", path: "/",
embedPath: "/vscode-embed", embedPath: "/vscode-embed",
} }
const options: Options = {
app,
authed: !!this.authenticated(request),
logLevel: logger.level,
} }
const response = await this.getUtf8Resource(this.rootPath, "src/browser/index.html") const response = await this.getUtf8Resource(this.rootPath, "src/browser/index.html")
response.content = response.content response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit) .replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`) .replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`)
.replace(/{{COMPONENT}}/g, ReactDOMServer.renderToString(<App options={options} />)) .replace(/{{COMPONENT}}/g, ReactDOMServer.renderToString(<App options={options} />))
return response return response
} }
}
return undefined
}
public async handleWebSocket(): Promise<undefined> { public async handleWebSocket(): Promise<undefined> {
return undefined return undefined

View file

@ -47,8 +47,9 @@ export interface HttpResponse<T = string | Buffer | object> {
content?: T content?: T
/** /**
* Cookie to write with the response. * Cookie to write with the response.
* NOTE: Cookie paths must be absolute. The default is /.
*/ */
cookie?: { key: string; value: string } cookie?: { key: string; value: string; path?: string }
/** /**
* Used to automatically determine the appropriate mime type. * Used to automatically determine the appropriate mime type.
*/ */
@ -64,7 +65,7 @@ export interface HttpResponse<T = string | Buffer | object> {
/** /**
* Redirect to this path. Will rewrite against the base path but NOT the * Redirect to this path. Will rewrite against the base path but NOT the
* provider endpoint so you must include it. This allows redirecting outside * provider endpoint so you must include it. This allows redirecting outside
* of your endpoint. Use `withBase()` to redirect within your endpoint. * of your endpoint.
*/ */
redirect?: string redirect?: string
/** /**
@ -87,9 +88,12 @@ export interface HttpStringFileResponse extends HttpResponse {
filePath: string filePath: string
} }
export interface RedirectResponse extends HttpResponse {
redirect: string
}
export interface HttpServerOptions { export interface HttpServerOptions {
readonly auth?: AuthType readonly auth?: AuthType
readonly basePath?: string
readonly cert?: string readonly cert?: string
readonly certKey?: string readonly certKey?: string
readonly commit?: string readonly commit?: string
@ -99,15 +103,18 @@ export interface HttpServerOptions {
readonly socket?: string readonly socket?: string
} }
interface ProviderRoute { export interface Route {
base: string base: string
requestPath: string requestPath: string
query: querystring.ParsedUrlQuery query: querystring.ParsedUrlQuery
provider: HttpProvider
fullPath: string fullPath: string
originalPath: string originalPath: string
} }
interface ProviderRoute extends Route {
provider: HttpProvider
}
export interface HttpProviderOptions { export interface HttpProviderOptions {
readonly base: string readonly base: string
readonly auth: AuthType readonly auth: AuthType
@ -132,9 +139,7 @@ export abstract class HttpProvider {
* Handle web sockets on the registered endpoint. * Handle web sockets on the registered endpoint.
*/ */
public abstract handleWebSocket( public abstract handleWebSocket(
base: string, route: Route,
requestPath: string,
query: querystring.ParsedUrlQuery,
request: http.IncomingMessage, request: http.IncomingMessage,
socket: net.Socket, socket: net.Socket,
head: Buffer head: Buffer
@ -143,24 +148,20 @@ export abstract class HttpProvider {
/** /**
* Handle requests to the registered endpoint. * Handle requests to the registered endpoint.
*/ */
public abstract handleRequest( public abstract handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined>
base: string,
requestPath: string, /**
query: querystring.ParsedUrlQuery, * Get the base relative to the provided route.
request: http.IncomingMessage */
): Promise<HttpResponse | undefined> public base(route: Route): string {
const depth = route.fullPath ? (route.fullPath.match(/\//g) || []).length : 1
return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : ""))
}
protected get isDev(): boolean { protected get isDev(): boolean {
return this.options.commit === "development" return this.options.commit === "development"
} }
/**
* Return the specified path with the base path prepended.
*/
protected withBase(path: string): string {
return normalize(`${this.options.base}/${path}`)
}
/** /**
* Get a file resource. * Get a file resource.
* TODO: Would a stream be faster, at least for large files? * TODO: Would a stream be faster, at least for large files?
@ -346,19 +347,14 @@ export class HttpServer {
private listenPromise: Promise<string | null> | undefined private listenPromise: Promise<string | null> | undefined
public readonly protocol: "http" | "https" public readonly protocol: "http" | "https"
private readonly providers = new Map<string, HttpProvider>() private readonly providers = new Map<string, HttpProvider>()
private readonly options: HttpServerOptions
private readonly heart: Heart private readonly heart: Heart
public constructor(options: HttpServerOptions) { public constructor(private readonly options: HttpServerOptions) {
this.heart = new Heart(path.join(xdgLocalDir, "heartbeat"), async () => { this.heart = new Heart(path.join(xdgLocalDir, "heartbeat"), async () => {
const connections = await this.getConnections() const connections = await this.getConnections()
logger.trace(`${connections} active connection${plural(connections)}`) logger.trace(`${connections} active connection${plural(connections)}`)
return connections !== 0 return connections !== 0
}) })
this.options = {
...options,
basePath: options.basePath ? options.basePath.replace(/\/+$/, "") : "",
}
this.protocol = this.options.cert ? "https" : "http" this.protocol = this.options.cert ? "https" : "http"
if (this.protocol === "https") { if (this.protocol === "https") {
this.server = httpolyglot.createServer( this.server = httpolyglot.createServer(
@ -452,30 +448,19 @@ export class HttpServer {
try { try {
this.heart.beat() this.heart.beat()
const route = this.parseUrl(request) const route = this.parseUrl(request)
const payload = const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request))
this.maybeRedirect(request, route) ||
(await route.provider.handleRequest(route.base, route.requestPath, route.query, request))
if (!payload) { if (!payload) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }
const basePath = this.options.basePath || "/"
response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, { response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, {
"Content-Type": payload.mime || getMediaMime(payload.filePath), "Content-Type": payload.mime || getMediaMime(payload.filePath),
...(payload.redirect ...(payload.redirect ? { Location: payload.redirect } : {}),
? { ...(request.headers["service-worker"] ? { "Service-Worker-Allowed": route.provider.base(route) } : {}),
Location: this.constructRedirect(
request.headers.host as string,
route.fullPath,
normalize(`${basePath}/${payload.redirect}`) + "/",
{ ...route.query, ...(payload.query || {}) }
),
}
: {}),
...(request.headers["service-worker"] ? { "Service-Worker-Allowed": basePath } : {}),
...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}), ...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}),
...(payload.cookie ...(payload.cookie
? { ? {
"Set-Cookie": `${payload.cookie.key}=${payload.cookie.value}; Path=${basePath}; HttpOnly; SameSite=strict`, "Set-Cookie": `${payload.cookie.key}=${payload.cookie.value}; Path=${payload.cookie.path ||
"/"}; HttpOnly; SameSite=strict`,
} }
: {}), : {}),
...payload.headers, ...payload.headers,
@ -497,9 +482,8 @@ export class HttpServer {
let e = error let e = error
if (error.code === "ENOENT" || error.code === "EISDIR") { if (error.code === "ENOENT" || error.code === "EISDIR") {
e = new HttpError("Not found", HttpCode.NotFound) e = new HttpError("Not found", HttpCode.NotFound)
} else {
logger.error(error.stack)
} }
logger.debug(error.stack)
response.writeHead(typeof e.code === "number" ? e.code : HttpCode.ServerError) response.writeHead(typeof e.code === "number" ? e.code : HttpCode.ServerError)
response.end(error.message) response.end(error.message)
} }
@ -509,14 +493,29 @@ export class HttpServer {
* Return any necessary redirection before delegating to a provider. * Return any necessary redirection before delegating to a provider.
*/ */
private maybeRedirect(request: http.IncomingMessage, route: ProviderRoute): HttpResponse | undefined { private maybeRedirect(request: http.IncomingMessage, route: ProviderRoute): HttpResponse | undefined {
// Redirect to HTTPS. const redirect = (path: string): string => {
if (this.options.cert && !(request.connection as tls.TLSSocket).encrypted) { Object.keys(route.query).forEach((key) => {
return { redirect: route.fullPath } if (typeof route.query[key] === "undefined") {
delete route.query[key]
} }
// Redirect indexes to a trailing slash so relative paths will operate })
// against the provider. // If we're handling TLS ensure all requests are redirected to HTTPS.
if (route.requestPath === "/index.html" && !route.originalPath.endsWith("/")) { return this.options.cert
return { redirect: route.fullPath } // Redirect always includes a trailing slash. ? `${this.protocol}://${request.headers.host}`
: "" +
normalize(`${route.provider.base(route)}/${path}`, true) +
(Object.keys(route.query).length > 0 ? `?${querystring.stringify(route.query)}` : "")
}
// Redirect to HTTPS if we're handling the TLS.
if (this.options.cert && !(request.connection as tls.TLSSocket).encrypted) {
return { redirect: redirect(route.fullPath) }
}
// Redirect our indexes to a trailing slash so relative paths in the served
// HTML will operate against the base path properly.
if (route.requestPath === "/index.html" && !route.originalPath.endsWith("/") && this.providers.has(route.base)) {
return { redirect: redirect(route.fullPath + "/") }
} }
return undefined return undefined
} }
@ -534,12 +533,12 @@ export class HttpServer {
throw new HttpError("HTTP/1.1 400 Bad Request", HttpCode.BadRequest) throw new HttpError("HTTP/1.1 400 Bad Request", HttpCode.BadRequest)
} }
const { base, requestPath, query, provider } = this.parseUrl(request) const route = this.parseUrl(request)
if (!provider) { if (!route.provider) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }
if (!(await provider.handleWebSocket(base, requestPath, query, request, socket, head))) { if (!(await route.provider.handleWebSocket(route, request, socket, head))) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }
} catch (error) { } catch (error) {
@ -593,21 +592,4 @@ export class HttpServer {
} }
return { base, fullPath, requestPath, query: parsedUrl.query, provider, originalPath } return { base, fullPath, requestPath, query: parsedUrl.query, provider, originalPath }
} }
/**
* Return the request URL with the specified base and new path.
*/
private constructRedirect(host: string, oldPath: string, newPath: string, query: Query): string {
if (oldPath && oldPath !== "/" && !query.to && /\/login(\/|$)/.test(newPath) && !/\/login(\/|$)/.test(oldPath)) {
query.to = oldPath
}
Object.keys(query).forEach((key) => {
if (typeof query[key] === "undefined") {
delete query[key]
}
})
return (
`${this.protocol}://${host}${newPath}` + (Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "")
)
}
} }

View file

@ -3,13 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<!-- <meta http-equiv="Content-Security-Policy" content="font-src 'self'; connect-src 'self'; default-src ws: wss:; style-src 'self'; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;"> --> <meta http-equiv="Content-Security-Policy" content="font-src 'self' fonts.gstatic.com; connect-src 'self'; default-src ws: wss: 'self'; style-src 'self' 'unsafe-inline' fonts.googleapis.com; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;">
<title>code-server</title> <title>code-server</title>
<link rel="icon" href="./static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="./static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="./static-{{COMMIT}}/src/browser/media/code-server.png" />
<link href="https://fonts.googleapis.com/css?family=IBM+Plex+Sans&display=swap" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}">
</head> </head>
<body> <body>
<div id="root" style="color:#f4f4f4;padding:20px;max-width:700px;"> <div id="root" style="color:#f4f4f4;padding:20px;max-width:700px;">

View file

@ -4,7 +4,6 @@ import * as crypto from "crypto"
import * as http from "http" import * as http from "http"
import * as net from "net" import * as net from "net"
import * as path from "path" import * as path from "path"
import * as querystring from "querystring"
import { import {
CodeServerMessage, CodeServerMessage,
Settings, Settings,
@ -13,7 +12,7 @@ import {
WorkbenchOptions, WorkbenchOptions,
} from "../../../lib/vscode/src/vs/server/ipc" } from "../../../lib/vscode/src/vs/server/ipc"
import { generateUuid } from "../../common/util" import { generateUuid } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse } from "../http" import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { SettingsProvider } from "../settings" import { SettingsProvider } from "../settings"
import { xdgLocalDir } from "../util" import { xdgLocalDir } from "../util"
@ -76,13 +75,7 @@ export class VscodeHttpProvider extends HttpProvider {
return this._vscode return this._vscode
} }
public async handleWebSocket( public async handleWebSocket(route: Route, request: http.IncomingMessage, socket: net.Socket): Promise<true> {
_base: string,
_requestPath: string,
query: querystring.ParsedUrlQuery,
request: http.IncomingMessage,
socket: net.Socket
): Promise<true> {
if (!this.authenticated(request)) { if (!this.authenticated(request)) {
throw new Error("not authenticated") throw new Error("not authenticated")
} }
@ -105,7 +98,7 @@ export class VscodeHttpProvider extends HttpProvider {
) )
const vscode = await this._vscode const vscode = await this._vscode
this.send({ type: "socket", query }, vscode, socket) this.send({ type: "socket", query: route.query }, vscode, socket)
return true return true
} }
@ -116,27 +109,20 @@ export class VscodeHttpProvider extends HttpProvider {
vscode.send(message, socket) vscode.send(message, socket)
} }
public async handleRequest( public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
base: string,
requestPath: string,
query: querystring.ParsedUrlQuery,
request: http.IncomingMessage
): Promise<HttpResponse | undefined> {
this.ensureGet(request) this.ensureGet(request)
switch (base) { this.ensureAuthenticated(request)
switch (route.base) {
case "/": case "/":
if (!this.authenticated(request)) {
return { redirect: "/login" }
}
try { try {
return await this.getRoot(request, query) return await this.getRoot(request, route)
} catch (error) { } catch (error) {
return this.getErrorRoot(error) return this.getErrorRoot(error)
} }
case "/static": { case "/static": {
switch (requestPath) { switch (route.requestPath) {
case "/out/vs/workbench/services/extensions/worker/extensionHostWorkerMain.js": { case "/out/vs/workbench/services/extensions/worker/extensionHostWorkerMain.js": {
const response = await this.getUtf8Resource(this.vsRootPath, requestPath) const response = await this.getUtf8Resource(this.vsRootPath, route.requestPath)
response.content = response.content.replace( response.content = response.content.replace(
/{{COMMIT}}/g, /{{COMMIT}}/g,
this.workbenchOptions ? this.workbenchOptions.commit : "" this.workbenchOptions ? this.workbenchOptions.commit : ""
@ -145,40 +131,37 @@ export class VscodeHttpProvider extends HttpProvider {
return response return response
} }
} }
const response = await this.getResource(this.vsRootPath, requestPath) const response = await this.getResource(this.vsRootPath, route.requestPath)
response.cache = true response.cache = true
return response return response
} }
case "/resource": case "/resource":
case "/vscode-remote-resource": case "/vscode-remote-resource":
this.ensureAuthenticated(request) if (typeof route.query.path === "string") {
if (typeof query.path === "string") { return this.getResource(route.query.path)
return this.getResource(query.path)
} }
break break
case "/tar": case "/tar":
this.ensureAuthenticated(request) if (typeof route.query.path === "string") {
if (typeof query.path === "string") { return this.getTarredResource(route.query.path)
return this.getTarredResource(query.path)
} }
break break
case "/webview": case "/webview":
this.ensureAuthenticated(request) if (/^\/vscode-resource/.test(route.requestPath)) {
if (/^\/vscode-resource/.test(requestPath)) { return this.getResource(route.requestPath.replace(/^\/vscode-resource(\/file)?/, ""))
return this.getResource(requestPath.replace(/^\/vscode-resource(\/file)?/, ""))
} }
return this.getResource(this.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", requestPath) return this.getResource(this.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", route.requestPath)
} }
return undefined return undefined
} }
private async getRoot(request: http.IncomingMessage, query: querystring.ParsedUrlQuery): Promise<HttpResponse> { private async getRoot(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
const settings = await this.settings.read() const settings = await this.settings.read()
const [response, options] = await Promise.all([ const [response, options] = await Promise.all([
await this.getUtf8Resource(this.rootPath, `src/node/vscode/workbench${!this.isDev ? "-build" : ""}.html`), await this.getUtf8Resource(this.rootPath, `src/node/vscode/workbench${!this.isDev ? "-build" : ""}.html`),
this.initialize({ this.initialize({
args: this.args, args: this.args,
query, query: route.query,
remoteAuthority: request.headers.host as string, remoteAuthority: request.headers.host as string,
settings, settings,
}), }),

View file

@ -19,10 +19,10 @@
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}"> <meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
<!-- Workbench Icon/Manifest/CSS --> <!-- Workbench Icon/Manifest/CSS -->
<link rel="icon" href="./static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="../static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="./static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials"> <link rel="manifest" href="../static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="./static-{{COMMIT}}/out/vs/workbench/workbench.web.api.css"> <link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="../static-{{COMMIT}}/out/vs/workbench/workbench.web.api.css">
<link rel="apple-touch-icon" href="./static-{{COMMIT}}/src/browser/media/code-server.png" /> <link rel="apple-touch-icon" href="../static-{{COMMIT}}/src/browser/media/code-server.png" />
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<!-- Prefetch to avoid waterfall --> <!-- Prefetch to avoid waterfall -->

View file

@ -19,9 +19,9 @@
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}"> <meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
<!-- Workbench Icon/Manifest/CSS --> <!-- Workbench Icon/Manifest/CSS -->
<link rel="icon" href="./static/src/browser/media/favicon.ico" type="image/x-icon" /> <link rel="icon" href="../static/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="./static/src/browser/media/manifest.json" crossorigin="use-credentials"> <link rel="manifest" href="../static/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="./static/src/browser/media/code-server.png" /> <link rel="apple-touch-icon" href="../static/src/browser/media/code-server.png" />
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
</head> </head>