Make routing base path agnostic
This commit is contained in:
parent
a149c5fc60
commit
4cc181cedc
|
@ -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",
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
126
src/node/http.ts
126
src/node/http.ts
|
@ -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)}` : "")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;">
|
||||||
|
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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 -->
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue