Archived
1
0

Simplify frontend

Just a login form and a list of applications. No modals or anything like
that.
This commit is contained in:
Asher
2020-02-13 16:38:05 -06:00
parent bf1be16d11
commit b8fa7da972
49 changed files with 984 additions and 1846 deletions

View File

@ -1,164 +0,0 @@
import { field, logger } from "@coder/logger"
import * as http from "http"
import * as net from "net"
import * as ws from "ws"
import {
Application,
ApplicationsResponse,
ClientMessage,
FilesResponse,
LoginRequest,
LoginResponse,
ServerMessage,
} from "../../common/api"
import { ApiEndpoint, HttpCode } from "../../common/http"
import { normalize } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
import { hash } from "../util"
export const Vscode: Application = {
name: "VS Code",
path: "/",
embedPath: "./vscode-embed",
}
/**
* API HTTP provider.
*/
export class ApiHttpProvider extends HttpProvider {
private readonly ws = new ws.Server({ noServer: true })
public constructor(options: HttpProviderOptions, private readonly server: HttpServer) {
super(options)
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
switch (route.base) {
case ApiEndpoint.login:
if (request.method === "POST") {
return this.login(request)
}
break
}
if (!this.authenticated(request)) {
return { code: HttpCode.Unauthorized }
}
switch (route.base) {
case ApiEndpoint.applications:
return this.applications()
case ApiEndpoint.files:
return this.files()
}
return undefined
}
public async handleWebSocket(
_route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer
): Promise<true> {
if (!this.authenticated(request)) {
throw new Error("not authenticated")
}
await new Promise<ws>((resolve) => {
this.ws.handleUpgrade(request, socket, head, (ws) => {
const send = (event: ServerMessage): void => {
ws.send(JSON.stringify(event))
}
ws.on("message", (data) => {
logger.trace("got message", field("message", data))
try {
const message: ClientMessage = JSON.parse(data.toString())
this.getMessageResponse(message.event).then(send)
} catch (error) {
logger.error(error.message, field("message", data))
}
})
resolve()
})
})
return true
}
private async getMessageResponse(event: "health"): Promise<ServerMessage> {
switch (event) {
case "health":
return { event, connections: await this.server.getConnections() }
default:
throw new Error("unexpected message")
}
}
/**
* Return OK and a cookie if the user is authenticated otherwise return
* unauthorized.
*/
private async login(request: http.IncomingMessage): Promise<HttpResponse<LoginResponse>> {
// Already authenticated via cookies?
const providedPassword = this.authenticated(request)
if (providedPassword) {
return { code: HttpCode.Ok }
}
const data = await this.getData(request)
const payload: LoginRequest = data ? JSON.parse(data) : {}
const password = this.authenticated(request, {
key: typeof payload.password === "string" ? [hash(payload.password)] : undefined,
})
if (password) {
return {
content: {
success: true,
// TEMP: Auto-load VS Code.
app: Vscode,
},
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(
"Failed login attempt",
JSON.stringify({
xForwardedFor: request.headers["x-forwarded-for"],
remoteAddress: request.connection.remoteAddress,
userAgent: request.headers["user-agent"],
timestamp: Math.floor(new Date().getTime() / 1000),
})
)
}
return { code: HttpCode.Unauthorized }
}
/**
* Return files at the requested directory.
*/
private async files(): Promise<HttpResponse<FilesResponse>> {
return {
content: {
files: [],
},
}
}
/**
* Return available applications.
*/
private async applications(): Promise<HttpResponse<ApplicationsResponse>> {
return {
content: {
applications: [Vscode],
},
}
}
}

304
src/node/app/api.ts Normal file
View File

@ -0,0 +1,304 @@
import { field, logger } from "@coder/logger"
import * as cp from "child_process"
import * as http from "http"
import * as net from "net"
import * as WebSocket from "ws"
import {
Application,
ApplicationsResponse,
ClientMessage,
RecentResponse,
ServerMessage,
SessionError,
SessionResponse,
} from "../../common/api"
import { ApiEndpoint, HttpCode } from "../../common/http"
import { normalize } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, HttpServer, Route } from "../http"
import { findApplications, findWhitelistedApplications } from "./bin"
interface ServerSession {
process?: cp.ChildProcess
readonly app: Application
}
/**
* API HTTP provider.
*/
export class ApiHttpProvider extends HttpProvider {
private readonly ws = new WebSocket.Server({ noServer: true })
private readonly sessions = new Map<string, ServerSession>()
public constructor(options: HttpProviderOptions, private readonly server: HttpServer) {
super(options)
}
public dispose(): void {
this.sessions.forEach((s) => {
if (s.process) {
s.process.kill()
}
})
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
if (!this.authenticated(request)) {
return { code: HttpCode.Unauthorized }
}
switch (route.base) {
case ApiEndpoint.applications:
this.ensureMethod(request)
return {
content: {
applications: await this.applications(),
},
} as HttpResponse<ApplicationsResponse>
case ApiEndpoint.session:
return this.session(request)
case ApiEndpoint.recent:
this.ensureMethod(request)
return {
content: await this.recent(),
} as HttpResponse<RecentResponse>
}
return undefined
}
public async handleWebSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer
): Promise<true | undefined> {
if (!this.authenticated(request)) {
throw new Error("not authenticated")
}
switch (route.base) {
case ApiEndpoint.status:
return this.handleStatusSocket(request, socket, head)
case ApiEndpoint.run:
return this.handleRunSocket(route, request, socket, head)
}
return undefined
}
private async handleStatusSocket(request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<true> {
const getMessageResponse = async (event: "health"): Promise<ServerMessage> => {
switch (event) {
case "health":
return { event, connections: await this.server.getConnections() }
default:
throw new Error("unexpected message")
}
}
await new Promise<WebSocket>((resolve) => {
this.ws.handleUpgrade(request, socket, head, (ws) => {
const send = (event: ServerMessage): void => {
ws.send(JSON.stringify(event))
}
ws.on("message", (data) => {
logger.trace("got message", field("message", data))
try {
const message: ClientMessage = JSON.parse(data.toString())
getMessageResponse(message.event).then(send)
} catch (error) {
logger.error(error.message, field("message", data))
}
})
resolve()
})
})
return true
}
/**
* A socket that connects to a session.
*/
private async handleRunSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer
): Promise<true> {
const sessionId = route.requestPath.replace(/^\//, "")
logger.debug("connecting session", field("sessionId", sessionId))
const ws = await new Promise<WebSocket>((resolve, reject) => {
this.ws.handleUpgrade(request, socket, head, (socket) => {
socket.binaryType = "arraybuffer"
const session = this.sessions.get(sessionId)
if (!session) {
socket.close(SessionError.NotFound)
return reject(new Error("session not found"))
}
resolve(socket as WebSocket)
socket.on("error", (error) => {
socket.close(SessionError.FailedToStart)
logger.error("got error while connecting socket", field("error", error))
reject(error)
})
})
})
// Send ready message.
ws.send(
Buffer.from(
JSON.stringify({
protocol: "TODO",
})
)
)
return true
}
/**
* Return whitelisted applications.
*/
public async applications(): Promise<ReadonlyArray<Application>> {
return findWhitelistedApplications()
}
/**
* Return installed applications.
*/
public async installedApplications(): Promise<ReadonlyArray<Application>> {
return findApplications()
}
/**
* Get a running application.
*/
public getRunningApplication(sessionIdOrPath?: string): Application | undefined {
if (!sessionIdOrPath) {
return undefined
}
const sessionId = sessionIdOrPath.replace(/\//g, "")
let session = this.sessions.get(sessionId)
if (session) {
logger.debug("found application by session id", field("id", sessionId))
return session.app
}
const base = normalize("/" + sessionIdOrPath)
session = Array.from(this.sessions.values()).find((s) => s.app.path === base)
if (session) {
logger.debug("found application by path", field("path", base))
return session.app
}
logger.debug("no application found matching route", field("route", sessionIdOrPath))
return undefined
}
/**
* Handle /session endpoint.
*/
private async session(request: http.IncomingMessage): Promise<HttpResponse | undefined> {
this.ensureMethod(request, ["DELETE", "POST"])
const data = await this.getData(request)
if (!data) {
return undefined
}
switch (request.method) {
case "DELETE":
return this.deleteSession(JSON.parse(data).sessionId)
case "POST": {
// Prevent spawning the same app multiple times.
const parsed: Application = JSON.parse(data)
const app = this.getRunningApplication(parsed.sessionId || parsed.path)
if (app) {
return {
content: {
created: false,
sessionId: app.sessionId,
},
} as HttpResponse<SessionResponse>
}
return {
content: {
created: true,
sessionId: await this.createSession(parsed),
},
} as HttpResponse<SessionResponse>
}
}
return undefined
}
/**
* Kill a session identified by `app.sessionId`.
*/
public deleteSession(sessionId: string): HttpResponse {
logger.debug("deleting session", field("sessionId", sessionId))
const session = this.sessions.get(sessionId)
if (!session) {
throw new Error("session does not exist")
}
if (session.process) {
session.process.kill()
}
this.sessions.delete(sessionId)
return { code: HttpCode.Ok }
}
/**
* Create a new session and return the session ID.
*/
public async createSession(app: Application): Promise<string> {
const sessionId = Math.floor(Math.random() * 10000).toString()
if (this.sessions.has(sessionId)) {
throw new Error("conflicting session id")
}
if (!app.exec) {
throw new Error("cannot execute application with no `exec`")
}
const appSession: ServerSession = {
app: {
...app,
sessionId,
},
}
this.sessions.set(sessionId, appSession)
try {
throw new Error("TODO")
} catch (error) {
this.sessions.delete(sessionId)
throw error
}
}
/**
* Return recent sessions.
*/
public async recent(): Promise<RecentResponse> {
return {
recent: [], // TODO
running: Array.from(this.sessions).map(([sessionId, session]) => ({
...session.app,
sessionId,
})),
}
}
/**
* For these, just return the error message since they'll be requested as
* JSON.
*/
public async getErrorRoot(_route: Route, _title: string, _header: string, error: string): Promise<HttpResponse> {
return {
content: JSON.stringify({ error }),
}
}
}

130
src/node/app/app.ts Normal file
View File

@ -0,0 +1,130 @@
import { logger } from "@coder/logger"
import * as http from "http"
import * as querystring from "querystring"
import { Application } from "../../common/api"
import { HttpCode, HttpError } from "../../common/http"
import { Options } from "../../common/util"
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
import { ApiHttpProvider } from "./api"
/**
* Top-level and fallback HTTP provider.
*/
export class MainHttpProvider extends HttpProvider {
public constructor(options: HttpProviderOptions, private readonly api: ApiHttpProvider) {
super(options)
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
switch (route.base) {
case "/static": {
this.ensureMethod(request)
const response = await this.getResource(this.rootPath, route.requestPath)
if (!this.isDev) {
response.cache = true
}
return response
}
case "/delete": {
this.ensureMethod(request, "POST")
const data = await this.getData(request)
const p = data ? querystring.parse(data) : {}
this.api.deleteSession(p.sessionId as string)
return { redirect: "/" }
}
case "/": {
this.ensureMethod(request)
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
} else if (!this.authenticated(request)) {
return { redirect: "/login" }
}
return this.getRoot(route)
}
}
// Run an existing app, but if it doesn't exist go ahead and start it.
let app = this.api.getRunningApplication(route.base)
let sessionId = app && app.sessionId
if (!app) {
app = (await this.api.installedApplications()).find((a) => a.path === route.base)
if (app) {
sessionId = await this.api.createSession(app)
}
}
if (sessionId) {
return this.getAppRoot(
route,
{
sessionId,
base: this.base(route),
logLevel: logger.level,
},
(app && app.name) || ""
)
}
return this.getErrorRoot(route, "404", "404", "Application not found")
}
public async getRoot(route: Route): Promise<HttpResponse> {
const recent = await this.api.recent()
const apps = await this.api.installedApplications()
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/home.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{APP_LIST:RUNNING}}/g, this.getAppRows(recent.running))
.replace(
/{{APP_LIST:EDITORS}}/g,
this.getAppRows(apps.filter((app) => app.categories && app.categories.includes("Editor")))
)
.replace(
/{{APP_LIST:OTHER}}/g,
this.getAppRows(apps.filter((app) => !app.categories || !app.categories.includes("Editor")))
)
return response
}
public async getAppRoot(route: Route, options: Options, name: string): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/app.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{APP_NAME}}/g, name)
.replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`)
return response
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
private getAppRows(apps: ReadonlyArray<Application>): string {
return apps.length > 0 ? apps.map((app) => this.getAppRow(app)).join("\n") : `<div class="none">None</div>`
}
private getAppRow(app: Application): string {
return `<div class="app-row">
<a class="open" href=".${app.path}">
${
app.icon
? `<img class="icon" src="data:image/png;base64,${app.icon}"></img>`
: `<div class="icon -missing"></div>`
}
<div class="name">${app.name}</div>
</a>
${
app.sessionId
? `<form class="kill-form" action="./delete" method="POST">
<input type="hidden" name="sessionId" value="${app.sessionId}">
<button class="kill" type="submit">Kill</button>
</form>`
: ""
}
</div>`
}
}

27
src/node/app/bin.ts Normal file
View File

@ -0,0 +1,27 @@
import * as path from "path"
import { Application } from "../../common/api"
const getVscodeVersion = (): string => {
try {
return require(path.resolve(__dirname, "../../../lib/vscode/package.json")).version
} catch (error) {
return "unknown"
}
}
export const Vscode: Application = {
categories: ["Editor"],
name: "VS Code",
path: "/vscode",
version: getVscodeVersion(),
}
export const findApplications = async (): Promise<ReadonlyArray<Application>> => {
const apps: Application[] = [Vscode]
return apps.sort((a, b): number => a.name.localeCompare(b.name))
}
export const findWhitelistedApplications = async (): Promise<ReadonlyArray<Application>> => {
return []
}

124
src/node/app/login.ts Normal file
View File

@ -0,0 +1,124 @@
import * as http from "http"
import * as querystring from "querystring"
import { HttpCode, HttpError } from "../../common/http"
import { AuthType, HttpProvider, HttpResponse, Route } from "../http"
import { hash } from "../util"
interface LoginPayload {
password?: string
/**
* Since we must set a cookie with an absolute path, we need to know the full
* base path.
*/
base?: string
}
/**
* Login HTTP provider.
*/
export class LoginHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
if (this.options.auth !== AuthType.Password) {
throw new HttpError("Not found", HttpCode.NotFound)
}
switch (route.base) {
case "/":
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
}
switch (request.method) {
case "POST":
return this.tryLogin(route, request)
default:
this.ensureMethod(request)
if (this.authenticated(request)) {
return {
redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/",
query: { to: undefined },
}
}
return this.getRoot(route)
}
}
return undefined
}
public async getRoot(route: Route, value?: string, error?: Error): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{VALUE}}/g, value || "")
.replace(/{{ERROR}}/g, error ? `<div class="error">${error.message}</div>` : "")
return response
}
/**
* Try logging in. On failure, show the login page with an error.
*/
private async tryLogin(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
// Already authenticated via cookies?
const providedPassword = this.authenticated(request)
if (providedPassword) {
return { code: HttpCode.Ok }
}
let payload: LoginPayload | undefined
try {
const data = await this.getData(request)
const p = data ? querystring.parse(data) : {}
payload = p
return await this.login(p, route, request)
} catch (error) {
return this.getRoot(route, payload ? payload.password : undefined, error)
}
}
/**
* Return a cookie if the user is authenticated otherwise throw an error.
*/
private async login(payload: LoginPayload, route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
const password = this.authenticated(request, {
key: typeof payload.password === "string" ? [hash(payload.password)] : undefined,
})
if (password) {
return {
redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/",
query: { to: undefined },
cookie:
typeof password === "string"
? {
key: "key",
value: password,
path: payload.base,
}
: undefined,
}
}
// Only log if it was an actual login attempt.
if (payload && payload.password) {
console.error(
"Failed login attempt",
JSON.stringify({
xForwardedFor: request.headers["x-forwarded-for"],
remoteAddress: request.connection.remoteAddress,
userAgent: request.headers["user-agent"],
timestamp: Math.floor(new Date().getTime() / 1000),
})
)
throw new Error("Incorrect password")
}
throw new Error("Missing password")
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
}

View File

@ -1,62 +0,0 @@
import { logger } from "@coder/logger"
import * as http from "http"
import * as React from "react"
import * as ReactDOMServer from "react-dom/server"
import App from "../../browser/app"
import { HttpCode, HttpError } from "../../common/http"
import { Options } from "../../common/util"
import { Vscode } from "../api/server"
import { HttpProvider, HttpResponse, Route } from "../http"
/**
* Top-level and fallback HTTP provider.
*/
export class MainHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
switch (route.base) {
case "/static": {
const response = await this.getResource(this.rootPath, route.requestPath)
if (!this.isDev) {
response.cache = true
}
return response
}
case "/vscode":
case "/": {
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
}
const options: Options = {
authed: !!this.authenticated(request),
basePath: this.base(route),
logLevel: logger.level,
}
// TODO: Load other apps based on the URL as well.
if (route.base === Vscode.path && options.authed) {
options.app = Vscode
}
return this.getRoot(route, options)
}
}
return undefined
}
public async getRoot(route: Route, options: Options): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/index.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/"{{OPTIONS}}"/g, `'${JSON.stringify(options)}'`)
.replace(/{{COMPONENT}}/g, ReactDOMServer.renderToString(<App options={options} />))
return response
}
public async handleWebSocket(): Promise<undefined> {
return undefined
}
}

View File

@ -118,18 +118,28 @@ export class VscodeHttpProvider extends HttpProvider {
}
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse | undefined> {
this.ensureGet(request)
this.ensureAuthenticated(request)
this.ensureMethod(request)
switch (route.base) {
case "/":
if (route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
} else if (!this.authenticated(request)) {
return { redirect: "/login", query: { to: this.options.base } }
}
try {
return await this.getRoot(request, route)
} catch (error) {
return this.getErrorRoot(error)
const message = `${
this.isDev ? "It might not have finished compiling (check for 'Finished compilation' in the output)." : ""
} <br><br>${error}`
return this.getErrorRoot(route, "VS Code failed to load", "VS Code failed to load", message)
}
}
this.ensureAuthenticated(request)
switch (route.base) {
case "/static": {
switch (route.requestPath) {
case "/out/vs/workbench/services/extensions/worker/extensionHostWorkerMain.js": {
@ -179,7 +189,7 @@ export class VscodeHttpProvider extends HttpProvider {
remoteAuthority
)
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/browser/pages/vscode.html"),
this.initialize({
args: this.args,
remoteAuthority,
@ -195,6 +205,10 @@ export class VscodeHttpProvider extends HttpProvider {
})
}
if (!this.isDev) {
response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "")
}
return {
...response,
content: response.content
@ -208,15 +222,6 @@ export class VscodeHttpProvider extends HttpProvider {
}
}
private async getErrorRoot(error: Error): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/node/vscode/error.html")
const message = `VS Code failed to load. ${
this.isDev ? "It might not have finished compiling (check for 'Finished compilation' in the output)." : ""
} <br><br>${error}`
response.content = response.content.replace(/{{COMMIT}}/g, this.options.commit).replace(/{{ERROR}}/g, message)
return response
}
/**
* Choose the first valid path. If `workspace` is undefined then either a
* workspace or a directory are acceptable. Otherwise it must be a file if a

View File

@ -1,13 +1,17 @@
import { logger } from "@coder/logger"
import { ApiHttpProvider } from "./api/server"
import { MainHttpProvider } from "./app/server"
import { Args, optionDescriptions, parse } from "./cli"
import { ApiHttpProvider } from "./app/api"
import { MainHttpProvider } from "./app/app"
import { LoginHttpProvider } from "./app/login"
import { VscodeHttpProvider } from "./app/vscode"
import { AuthType, HttpServer } from "./http"
import { generateCertificate, generatePassword, hash, open } from "./util"
import { VscodeHttpProvider } from "./vscode/server"
import { ipcMain, wrap } from "./wrapper"
const main = async (args: Args): Promise<void> => {
// For any future forking bypass nbin and drop straight to Node.
process.env.NBIN_BYPASS = "true"
const auth = args.auth || AuthType.Password
const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword()))
@ -36,9 +40,10 @@ const main = async (args: Args): Promise<void> => {
}
const httpServer = new HttpServer(options)
httpServer.registerHttpProvider("/", MainHttpProvider)
httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer)
httpServer.registerHttpProvider("/vscode-embed", VscodeHttpProvider, args)
const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer)
httpServer.registerHttpProvider("/vscode", VscodeHttpProvider, args)
httpServer.registerHttpProvider("/login", LoginHttpProvider)
httpServer.registerHttpProvider("/", MainHttpProvider, api)
ipcMain().onDispose(() => httpServer.dispose())

View File

@ -164,6 +164,17 @@ export abstract class HttpProvider {
return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : ""))
}
public async getErrorRoot(route: Route, title: string, header: string, body: string): Promise<HttpResponse> {
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/error.html")
response.content = response.content
.replace(/{{COMMIT}}/g, this.options.commit)
.replace(/{{BASE}}/g, this.base(route))
.replace(/{{ERROR_TITLE}}/g, title)
.replace(/{{ERROR_HEADER}}/g, header)
.replace(/{{ERROR_BODY}}/g, body)
return response
}
protected get isDev(): boolean {
return this.options.commit === "development"
}
@ -194,10 +205,11 @@ export abstract class HttpProvider {
}
/**
* Helper to error on anything that's not a GET.
* Helper to error on invalid methods (default GET).
*/
protected ensureGet(request: http.IncomingMessage): void {
if (request.method !== "GET") {
protected ensureMethod(request: http.IncomingMessage, method?: string | string[]): void {
const check = Array.isArray(method) ? method : [method || "GET"]
if (!request.method || !check.includes(request.method)) {
throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest)
}
}
@ -390,14 +402,10 @@ export class HttpServer {
/**
* Register a provider for a top-level endpoint.
*/
public registerHttpProvider<T extends HttpProvider>(endpoint: string, provider: HttpProvider0<T>): void
public registerHttpProvider<A1, T extends HttpProvider>(
endpoint: string,
provider: HttpProvider1<A1, T>,
a1: A1
): void
public registerHttpProvider<T extends HttpProvider>(endpoint: string, provider: HttpProvider0<T>): T
public registerHttpProvider<A1, T extends HttpProvider>(endpoint: string, provider: HttpProvider1<A1, T>, a1: A1): T
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public registerHttpProvider(endpoint: string, provider: any, a1?: any): void {
public registerHttpProvider(endpoint: string, provider: any, a1?: any): any {
endpoint = endpoint.replace(/^\/+|\/+$/g, "")
if (this.providers.has(`/${endpoint}`)) {
throw new Error(`${endpoint} is already registered`)
@ -405,18 +413,17 @@ export class HttpServer {
if (/\//.test(endpoint)) {
throw new Error(`Only top-level endpoints are supported (got ${endpoint})`)
}
this.providers.set(
`/${endpoint}`,
new provider(
{
auth: this.options.auth || AuthType.None,
base: `/${endpoint}`,
commit: this.options.commit,
password: this.options.password,
},
a1
)
const p = new provider(
{
auth: this.options.auth || AuthType.None,
base: `/${endpoint}`,
commit: this.options.commit,
password: this.options.password,
},
a1
)
this.providers.set(`/${endpoint}`, p)
return p
}
/**
@ -451,22 +458,26 @@ export class HttpServer {
}
private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise<void> => {
this.heart.beat()
const route = this.parseUrl(request)
try {
this.heart.beat()
const route = this.parseUrl(request)
const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request))
if (!payload) {
throw new HttpError("Not found", HttpCode.NotFound)
}
response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, {
"Content-Type": payload.mime || getMediaMime(payload.filePath),
...(payload.redirect ? { Location: payload.redirect } : {}),
...(payload.redirect ? { Location: this.constructRedirect(request, route, payload as RedirectResponse) } : {}),
...(request.headers["service-worker"] ? { "Service-Worker-Allowed": route.provider.base(route) } : {}),
...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}),
...(payload.cookie
? {
"Set-Cookie": `${payload.cookie.key}=${payload.cookie.value}; Path=${payload.cookie.path ||
"/"}; HttpOnly; SameSite=strict`,
"Set-Cookie": [
`${payload.cookie.key}=${payload.cookie.value}`,
`Path=${normalize(payload.cookie.path || "/", true)}`,
"HttpOnly",
"SameSite=strict",
].join(";"),
}
: {}),
...payload.headers,
@ -490,37 +501,49 @@ export class HttpServer {
e = new HttpError("Not found", HttpCode.NotFound)
}
logger.debug(error.stack)
response.writeHead(typeof e.code === "number" ? e.code : HttpCode.ServerError)
response.end(error.message)
const code = typeof e.code === "number" ? e.code : HttpCode.ServerError
const content = (await route.provider.getErrorRoot(route, code, code, e.message)).content
response.writeHead(code)
response.end(content)
}
}
/**
* Return any necessary redirection before delegating to a provider.
*/
private maybeRedirect(request: http.IncomingMessage, route: ProviderRoute): HttpResponse | undefined {
const redirect = (path: string): string => {
Object.keys(route.query).forEach((key) => {
if (typeof route.query[key] === "undefined") {
delete route.query[key]
}
})
// If we're handling TLS ensure all requests are redirected to HTTPS.
return this.options.cert
? `${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.
private maybeRedirect(request: http.IncomingMessage, route: ProviderRoute): RedirectResponse | undefined {
// If we're handling TLS ensure all requests are redirected to HTTPS.
if (this.options.cert && !(request.connection as tls.TLSSocket).encrypted) {
return { redirect: redirect(route.fullPath) }
return { redirect: route.fullPath }
}
return undefined
}
/**
* Given a path that goes from the base, construct a relative redirect URL
* that will get you there considering that the app may be served from an
* unknown base path. If handling TLS, also ensure HTTPS.
*/
private constructRedirect(request: http.IncomingMessage, route: ProviderRoute, payload: RedirectResponse): string {
const query = {
...route.query,
...(payload.query || {}),
}
Object.keys(query).forEach((key) => {
if (typeof query[key] === "undefined") {
delete query[key]
}
})
return (
(this.options.cert ? `${this.protocol}://${request.headers.host}` : "") +
normalize(`${route.provider.base(route)}/${payload.redirect}`, true) +
(Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "")
)
}
private onUpgrade = async (request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> => {
try {
this.heart.beat()

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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 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>
</head>
<body style="background-color:#272727;">
<div id="root" style="color:#f4f4f4;padding:20px;max-width:700px;">
{{ERROR}}
</div>
<script>
if (parent) {
parent.postMessage({ event: "loaded" }, window.location.origin);
}
</script>
</body>
</html>

View File

@ -1,90 +0,0 @@
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<!-- Disable pinch zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<!-- Workbench Configuration -->
<meta id="vscode-workbench-web-configuration" data-settings="{{WORKBENCH_WEB_CONFIGURATION}}">
<!-- Workarounds/Hacks (remote user data uri) -->
<meta id="vscode-remote-user-data-uri" data-settings="{{REMOTE_USER_DATA_URI}}">
<!-- NOTE@coder: Added the commit for use in caching, the product for the
extensions gallery URL, and nls for language support. -->
<meta id="vscode-remote-commit" data-settings="{{COMMIT}}">
<meta id="vscode-remote-product-configuration" data-settings="{{PRODUCT_CONFIGURATION}}">
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
<!-- Workbench Icon/Manifest/CSS -->
<link rel="icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials">
<link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.css">
<link rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Prefetch to avoid waterfall -->
<link rel="prefetch" href="{{BASE}}/static-{{COMMIT}}/node_modules/semver-umd/lib/semver-umd.js">
</head>
<body aria-label="">
</body>
<!-- Startup (do not modify order of script tags!) -->
<script>
const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = "{{VS_BASE}}"
const url = new URL(window.location.origin + "/" + parts.join("/"))
const el = document.getElementById('vscode-remote-commit');
const commit = el ? el.getAttribute('data-settings') : "";
const staticBase = url.href + '/static-' + commit;
let nlsConfig;
try {
nlsConfig = JSON.parse(document.getElementById('vscode-remote-nls-configuration').getAttribute('data-settings'));
if (nlsConfig._resolvedLanguagePackCoreLocation) {
const bundles = Object.create(null);
nlsConfig.loadBundle = (bundle, language, cb) => {
let result = bundles[bundle];
if (result) {
return cb(undefined, result);
}
// FIXME: Only works if path separators are /.
const path = nlsConfig._resolvedLanguagePackCoreLocation
+ '/' + bundle.replace(/\//g, '!') + '.nls.json';
fetch(`${url.href}/resource/?path=${encodeURIComponent(path)}`)
.then((response) => response.json())
.then((json) => {
bundles[bundle] = json;
cb(undefined, json);
})
.catch(cb);
};
}
} catch (error) { /* Probably fine. */ }
self.require = {
baseUrl: `${staticBase}/out`,
paths: {
'vscode-textmate': `${staticBase}/node_modules/vscode-textmate/release/main`,
'onigasm-umd': `${staticBase}/node_modules/onigasm-umd/release/main`,
'xterm': `${staticBase}/node_modules/xterm/lib/xterm.js`,
'xterm-addon-search': `${staticBase}/node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
'xterm-addon-web-links': `${staticBase}/node_modules/xterm-addon-web-links/lib/xterm-addon-web-links.js`,
'xterm-addon-webgl': `${staticBase}/node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
'semver-umd': `${staticBase}/node_modules/semver-umd/lib/semver-umd.js`,
},
'vs/nls': nlsConfig,
};
</script>
<script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/loader.js"></script>
<script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.nls.js"></script>
<script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.js"></script>
<!-- TODO@coder: This errors with multiple anonymous define calls (one is
workbench.js and one is semver-umd.js). For now use the same method found in
workbench-dev.html. Appears related to the timing of the script load events. -->
<!-- <script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.js"></script> -->
<script>
require(['vs/code/browser/workbench/workbench'], function() {});
</script>
</html>

View File

@ -1,56 +0,0 @@
<!-- Copyright (C) Microsoft Corporation. All rights reserved. -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<!-- Disable pinch zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<!-- Workbench Configuration -->
<meta id="vscode-workbench-web-configuration" data-settings="{{WORKBENCH_WEB_CONFIGURATION}}">
<!-- Workarounds/Hacks (remote user data uri) -->
<meta id="vscode-remote-user-data-uri" data-settings="{{REMOTE_USER_DATA_URI}}">
<!-- NOTE@coder: Added the commit for use in caching, the product for the
extensions gallery URL, and nls for language support. -->
<meta id="vscode-remote-commit" data-settings="{{COMMIT}}">
<meta id="vscode-remote-product-configuration" data-settings="{{PRODUCT_CONFIGURATION}}">
<meta id="vscode-remote-nls-configuration" data-settings="{{NLS_CONFIGURATION}}">
<!-- Workbench Icon/Manifest/CSS -->
<link rel="icon" href="{{BASE}}/static/src/browser/media/favicon.ico" type="image/x-icon" />
<link rel="manifest" href="{{BASE}}/static/src/browser/media/manifest.json" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="{{BASE}}/static/src/browser/media/code-server.png" />
<meta name="apple-mobile-web-app-capable" content="yes">
</head>
<body aria-label="">
</body>
<!-- Startup (do not modify order of script tags!) -->
<script>
const parts = window.location.pathname.replace(/^\//g, "").split("/")
parts[parts.length - 1] = "{{VS_BASE}}"
const url = new URL(window.location.origin + "/" + parts.join("/"))
const el = document.getElementById('vscode-remote-commit');
const commit = el ? el.getAttribute('data-settings') : "";
const staticBase = url.href + '/static-' + commit;
self.require = {
baseUrl: `${staticBase}/out`,
paths: {
'vscode-textmate': `${staticBase}/node_modules/vscode-textmate/release/main`,
'onigasm-umd': `${staticBase}/node_modules/onigasm-umd/release/main`,
'xterm': `${staticBase}/node_modules/xterm/lib/xterm.js`,
'xterm-addon-search': `${staticBase}/node_modules/xterm-addon-search/lib/xterm-addon-search.js`,
'xterm-addon-web-links': `${staticBase}/node_modules/xterm-addon-web-links/lib/xterm-addon-web-links.js`,
'xterm-addon-webgl': `${staticBase}/node_modules/xterm-addon-webgl/lib/xterm-addon-webgl.js`,
'semver-umd': `${staticBase}/node_modules/semver-umd/lib/semver-umd.js`,
},
};
</script>
<script src="{{VS_BASE}}/static/out/vs/loader.js"></script>
<script>
require(['vs/code/browser/workbench/workbench'], function() {});
</script>
</html>