Simplify frontend
Just a login form and a list of applications. No modals or anything like that.
This commit is contained in:
parent
bf1be16d11
commit
b8fa7da972
@ -8,32 +8,16 @@ env:
|
||||
parserOptions:
|
||||
ecmaVersion: 2018
|
||||
sourceType: module
|
||||
ecmaFeatures:
|
||||
jsx: true
|
||||
|
||||
extends:
|
||||
- eslint:recommended
|
||||
- plugin:@typescript-eslint/recommended
|
||||
- plugin:import/recommended
|
||||
- plugin:import/typescript
|
||||
- plugin:react/recommended
|
||||
- plugin:prettier/recommended
|
||||
- prettier # Removes eslint rules that conflict with prettier.
|
||||
- prettier/@typescript-eslint # Remove conflicts again.
|
||||
|
||||
plugins:
|
||||
- react-hooks
|
||||
|
||||
# Need to set this explicitly for the eslint-plugin-react.
|
||||
settings:
|
||||
react:
|
||||
version: detect
|
||||
|
||||
rules:
|
||||
# For overloads.
|
||||
no-dupe-class-members: off
|
||||
|
||||
# https://www.npmjs.com/package/eslint-plugin-react-hooks
|
||||
react-hooks/rules-of-hooks: error
|
||||
|
||||
react/prop-types: off # We use Typescript to verify prop types.
|
||||
|
@ -22,13 +22,10 @@
|
||||
"devDependencies": {
|
||||
"@coder/nbin": "^1.2.7",
|
||||
"@types/fs-extra": "^8.0.1",
|
||||
"@types/hookrouter": "^2.2.1",
|
||||
"@types/mocha": "^5.2.7",
|
||||
"@types/node": "^12.12.7",
|
||||
"@types/parcel-bundler": "^1.12.1",
|
||||
"@types/pem": "^1.9.5",
|
||||
"@types/react": "^16.9.18",
|
||||
"@types/react-dom": "^16.9.5",
|
||||
"@types/safe-compare": "^1.1.0",
|
||||
"@types/tar-fs": "^1.16.1",
|
||||
"@types/tar-stream": "^1.6.1",
|
||||
@ -39,8 +36,6 @@
|
||||
"eslint-config-prettier": "^6.0.0",
|
||||
"eslint-plugin-import": "^2.18.2",
|
||||
"eslint-plugin-prettier": "^3.1.0",
|
||||
"eslint-plugin-react": "^7.14.3",
|
||||
"eslint-plugin-react-hooks": "^1.7.0",
|
||||
"leaked-handles": "^5.2.0",
|
||||
"mocha": "^6.2.0",
|
||||
"parcel-bundler": "^1.12.4",
|
||||
@ -58,11 +53,8 @@
|
||||
"dependencies": {
|
||||
"@coder/logger": "1.1.11",
|
||||
"fs-extra": "^8.1.0",
|
||||
"hookrouter": "^1.2.3",
|
||||
"httpolyglot": "^0.1.2",
|
||||
"pem": "^1.14.2",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"safe-compare": "^1.1.4",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tar-stream": "^2.1.0",
|
||||
|
@ -247,7 +247,10 @@ class Builder {
|
||||
|
||||
if (process.env.MINIFY) {
|
||||
await this.task(`restricting ${name} to production dependencies`, async () => {
|
||||
return util.promisify(cp.exec)("yarn --production --ignore-scripts", { cwd: buildPath })
|
||||
await util.promisify(cp.exec)("yarn --production --ignore-scripts", { cwd: buildPath })
|
||||
if (name === "code-server") {
|
||||
await util.promisify(cp.exec)("yarn postinstall", { cwd: buildPath })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -419,7 +422,7 @@ class Builder {
|
||||
}
|
||||
|
||||
private createBundler(out = "dist", commit?: string): Bundler {
|
||||
return new Bundler(path.join(this.rootPath, "src/browser/index.tsx"), {
|
||||
return new Bundler(path.join(this.rootPath, "src/browser/pages/app.ts"), {
|
||||
cache: true,
|
||||
cacheDir: path.join(this.rootPath, ".cache"),
|
||||
detailedReport: true,
|
||||
|
9
scripts/code-server.sh
Executable file
9
scripts/code-server.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# code-server.sh -- Run code-server with the bundled Node binary.
|
||||
|
||||
main() {
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
./node ./out/node/entry.js "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
@ -1,82 +0,0 @@
|
||||
import { getBasepath } from "hookrouter"
|
||||
import {
|
||||
Application,
|
||||
ApplicationsResponse,
|
||||
CreateSessionResponse,
|
||||
FilesResponse,
|
||||
LoginResponse,
|
||||
RecentResponse,
|
||||
} from "../common/api"
|
||||
import { ApiEndpoint, HttpCode, HttpError } from "../common/http"
|
||||
|
||||
export interface AuthBody {
|
||||
password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authenticated status.
|
||||
*/
|
||||
export function setAuthed(authed: boolean): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).setAuthed(authed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Try making a request. Throw an error if the request is anything except OK.
|
||||
* Also set authed to false if the request returns unauthorized.
|
||||
*/
|
||||
const tryRequest = async (endpoint: string, options?: RequestInit): Promise<Response> => {
|
||||
const response = await fetch(getBasepath() + "/api" + endpoint + "/", options)
|
||||
if (response.status === HttpCode.Unauthorized) {
|
||||
setAuthed(false)
|
||||
}
|
||||
if (response.status !== HttpCode.Ok) {
|
||||
const text = await response.text()
|
||||
throw new HttpError(text || response.statusText || "unknown error", response.status)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Try authenticating.
|
||||
*/
|
||||
export const authenticate = async (body?: AuthBody): Promise<LoginResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.login, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, basePath: getBasepath() }),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
},
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const getFiles = async (): Promise<FilesResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.files)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const getRecent = async (): Promise<RecentResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.recent)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const getApplications = async (): Promise<ApplicationsResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.applications)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const getSession = async (app: Application): Promise<CreateSessionResponse> => {
|
||||
const response = await tryRequest(ApiEndpoint.session, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(app),
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const killSession = async (app: Application): Promise<Response> => {
|
||||
return tryRequest(ApiEndpoint.session, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(app),
|
||||
})
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
import { field, logger } from "@coder/logger"
|
||||
import { getBasepath, navigate, setBasepath } from "hookrouter"
|
||||
import * as React from "react"
|
||||
import { Application, isExecutableApplication } from "../common/api"
|
||||
import { HttpError } from "../common/http"
|
||||
import { normalize, Options } from "../common/util"
|
||||
import { Logo } from "./components/logo"
|
||||
import { Modal } from "./components/modal"
|
||||
|
||||
export interface AppProps {
|
||||
options: Options
|
||||
}
|
||||
|
||||
interface RedirectedApplication extends Application {
|
||||
redirected?: boolean
|
||||
}
|
||||
|
||||
let resolved = false
|
||||
const App: React.FunctionComponent<AppProps> = (props) => {
|
||||
const [authed, setAuthed] = React.useState<boolean>(props.options.authed)
|
||||
const [app, setApp] = React.useState<RedirectedApplication | undefined>(props.options.app)
|
||||
const [error, setError] = React.useState<HttpError | Error | string>()
|
||||
|
||||
if (!resolved && typeof document !== "undefined") {
|
||||
// Get the base path. We need the full URL for connecting the web socket.
|
||||
// Use the path name plus the provided base path. For example:
|
||||
// foo.com/base + ./ => foo.com/base
|
||||
// foo.com/base/ + ./ => foo.com/base
|
||||
// foo.com/base/bar + ./ => foo.com/base
|
||||
// foo.com/base/bar/ + ./../ => foo.com/base
|
||||
const parts = window.location.pathname.replace(/^\//g, "").split("/")
|
||||
parts[parts.length - 1] = props.options.basePath
|
||||
const url = new URL(window.location.origin + "/" + parts.join("/"))
|
||||
setBasepath(normalize(url.pathname))
|
||||
logger.debug("resolved base path", field("base", getBasepath()))
|
||||
resolved = true
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(window as any).setAuthed = (a: boolean): void => {
|
||||
if (authed !== a) {
|
||||
setAuthed(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (app && !isExecutableApplication(app) && !app.redirected) {
|
||||
navigate(normalize(`${getBasepath()}/${app.path}/`, true))
|
||||
setApp({ ...app, redirected: true })
|
||||
}
|
||||
}, [app])
|
||||
|
||||
return (
|
||||
<>
|
||||
{!app || !app.loaded ? (
|
||||
<div className="coder-splash">
|
||||
<Logo />
|
||||
</div>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
<Modal app={app} setApp={setApp} authed={authed} error={error} setError={setError} />
|
||||
{authed && app && app.embedPath && app.redirected ? (
|
||||
<iframe id="iframe" src={normalize(`./${app.embedPath}/`, true)}></iframe>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
@ -1,31 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
export interface DelayProps {
|
||||
readonly show: boolean
|
||||
readonly delay: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a component before unmounting (by delaying unmounting) or after
|
||||
* mounting.
|
||||
*/
|
||||
export const Animate: React.FunctionComponent<DelayProps> = (props) => {
|
||||
const [timer, setTimer] = React.useState<NodeJS.Timeout>()
|
||||
const [mount, setMount] = React.useState<boolean>(false)
|
||||
const [visible, setVisible] = React.useState<boolean>(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (!props.show) {
|
||||
setVisible(false)
|
||||
setTimer(setTimeout(() => setMount(false), props.delay))
|
||||
} else {
|
||||
setTimer(setTimeout(() => setVisible(true), props.delay))
|
||||
setMount(true)
|
||||
}
|
||||
}, [props])
|
||||
|
||||
return mount ? <div className={`animate -${visible ? "show" : "hide"}`}>{props.children}</div> : null
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
.field-error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.request-error {
|
||||
align-items: center;
|
||||
color: rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.request-error > .close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.request-error + .request-error {
|
||||
border-top: 1px solid #b6b6b6;
|
||||
}
|
@ -1,49 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { HttpError } from "../../common/http"
|
||||
|
||||
export interface ErrorProps {
|
||||
error: HttpError | Error | string
|
||||
onClose?: () => void
|
||||
onCloseText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* An error to be displayed in a section where a request has failed.
|
||||
*/
|
||||
export const RequestError: React.FunctionComponent<ErrorProps> = (props) => {
|
||||
return (
|
||||
<div className="request-error">
|
||||
<div className="error">{typeof props.error === "string" ? props.error : props.error.message}</div>
|
||||
{props.onClose ? (
|
||||
<button className="close" onClick={props.onClose}>
|
||||
{props.onCloseText || "Close"}
|
||||
</button>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a more human/natural/useful message for some error codes resulting
|
||||
* from a form submission.
|
||||
*/
|
||||
const humanizeFormError = (error: HttpError | Error | string): string => {
|
||||
if (typeof error === "string") {
|
||||
return error
|
||||
}
|
||||
switch ((error as HttpError).code) {
|
||||
case 401:
|
||||
return "Wrong password"
|
||||
default:
|
||||
return error.message
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An error to be displayed underneath a field.
|
||||
*/
|
||||
export const FieldError: React.FunctionComponent<ErrorProps> = (props) => {
|
||||
return <div className="field-error">{humanizeFormError(props.error)}</div>
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
.app-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0 -10px; /* To counter app padding. */
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-loader {
|
||||
align-items: center;
|
||||
color: #b6b6b6;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-loader > .loader {
|
||||
color: #b6b6b6;
|
||||
}
|
||||
|
||||
.app-row {
|
||||
color: #b6b6b6;
|
||||
display: flex;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-row > .launch,
|
||||
.app-row > .kill {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
margin: 1px 0;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.app-row > .launch {
|
||||
border-radius: 50px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-row > .launch:hover,
|
||||
.app-row > .kill:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.app-row .icon {
|
||||
height: 1em;
|
||||
margin-right: 5px;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.app-row .icon.-missing {
|
||||
background-color: #eee;
|
||||
color: #b6b6b6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-row .icon.-missing::after {
|
||||
content: "?";
|
||||
font-size: 0.7em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.app-row.-selected {
|
||||
background-color: #bcc6fa;
|
||||
}
|
||||
|
||||
.app-loader > .opening {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.app-loader > .app-row {
|
||||
color: #000;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-loader > .cancel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-list + .app-list {
|
||||
border-top: 1px solid #b6b6b6;
|
||||
margin-top: 1em;
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.app-list > .header {
|
||||
color: #b6b6b6;
|
||||
font-size: 1em;
|
||||
margin-bottom: 1em;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.app-list > .loader {
|
||||
color: #b6b6b6;
|
||||
}
|
||||
|
||||
.app-list > .app-row {
|
||||
cursor: pointer;
|
||||
}
|
@ -1,201 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Application, isExecutableApplication, isRunningApplication } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { getSession, killSession } from "../api"
|
||||
import { RequestError } from "../components/error"
|
||||
|
||||
/**
|
||||
* An application's details (name and icon).
|
||||
*/
|
||||
export const AppDetails: React.FunctionComponent<Application> = (props) => {
|
||||
return (
|
||||
<>
|
||||
{props.icon ? (
|
||||
<img className="icon" src={`data:image/png;base64,${props.icon}`}></img>
|
||||
) : (
|
||||
<div className="icon -missing"></div>
|
||||
)}
|
||||
<div className="name">{props.name}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppRowProps {
|
||||
readonly app: Application
|
||||
onKilled(app: Application): void
|
||||
open(app: Application): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A single application row. Can be killed if it's a running application.
|
||||
*/
|
||||
export const AppRow: React.FunctionComponent<AppRowProps> = (props) => {
|
||||
const [killing, setKilling] = React.useState<boolean>(false)
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
function kill(): void {
|
||||
if (isRunningApplication(props.app)) {
|
||||
setKilling(true)
|
||||
killSession(props.app)
|
||||
.then(() => {
|
||||
setKilling(false)
|
||||
props.onKilled(props.app)
|
||||
})
|
||||
.catch((error) => {
|
||||
setError(error)
|
||||
setKilling(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-row">
|
||||
<button className="launch" onClick={(): void => props.open(props.app)}>
|
||||
<AppDetails {...props.app} />
|
||||
</button>
|
||||
{isRunningApplication(props.app) && !killing ? (
|
||||
<button className="kill" onClick={(): void => kill()}>
|
||||
{error ? error.message : killing ? "..." : "kill"}
|
||||
</button>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AppListProps {
|
||||
readonly header: string
|
||||
readonly apps?: ReadonlyArray<Application>
|
||||
open(app: Application): void
|
||||
onKilled(app: Application): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of applications. If undefined, show loading text. If empty, show a
|
||||
* message saying no items are found. Applications can be clicked and killed
|
||||
* (when applicable).
|
||||
*/
|
||||
export const AppList: React.FunctionComponent<AppListProps> = (props) => {
|
||||
return (
|
||||
<div className="app-list">
|
||||
<h2 className="header">{props.header}</h2>
|
||||
{props.apps && props.apps.length > 0 ? (
|
||||
props.apps.map((app, i) => <AppRow key={i} app={app} {...props} />)
|
||||
) : props.apps ? (
|
||||
<RequestError error={`No ${props.header.toLowerCase()} found`} />
|
||||
) : (
|
||||
<div className="loader">loading...</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ApplicationSection {
|
||||
readonly apps?: ReadonlyArray<Application>
|
||||
readonly header: string
|
||||
}
|
||||
|
||||
export interface AppLoaderProps {
|
||||
readonly app?: Application
|
||||
setApp(app?: Application): void
|
||||
getApps(): Promise<ReadonlyArray<ApplicationSection>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Application sections/groups. Handles loading of the application
|
||||
* sections, errors, opening applications, and killing applications.
|
||||
*/
|
||||
export const AppLoader: React.FunctionComponent<AppLoaderProps> = (props) => {
|
||||
const [apps, setApps] = React.useState<ReadonlyArray<ApplicationSection>>()
|
||||
const [error, setError] = React.useState<HttpError | Error>()
|
||||
|
||||
const refresh = (): void => {
|
||||
props
|
||||
.getApps()
|
||||
.then(setApps)
|
||||
.catch((e) => setError(e.message))
|
||||
}
|
||||
|
||||
// Every time the component loads go ahead and refresh the list.
|
||||
React.useEffect(() => {
|
||||
refresh()
|
||||
}, [props])
|
||||
|
||||
/**
|
||||
* Open an application if not already open. For executable applications create
|
||||
* a session first.
|
||||
*/
|
||||
function open(app: Application): void {
|
||||
if (props.app && props.app.name === app.name) {
|
||||
return setError(new Error(`${app.name} is already open`))
|
||||
}
|
||||
props.setApp(app)
|
||||
if (!isRunningApplication(app) && isExecutableApplication(app)) {
|
||||
getSession(app)
|
||||
.then((session) => {
|
||||
props.setApp({ ...app, ...session })
|
||||
})
|
||||
.catch((error) => {
|
||||
props.setApp(undefined)
|
||||
setError(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// In the case of an error fetching the apps, have the ability to try again.
|
||||
// In the case of failing to load an app, have the ability to go back to the
|
||||
// list (where the user can try again if they wish).
|
||||
if (error) {
|
||||
return (
|
||||
<RequestError
|
||||
error={error}
|
||||
onCloseText={props.app ? "Go Back" : "Try Again"}
|
||||
onClose={(): void => {
|
||||
setError(undefined)
|
||||
if (!props.app) {
|
||||
refresh()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// If an app is currently loading, provide the option to cancel.
|
||||
if (props.app && !props.app.loaded) {
|
||||
return (
|
||||
<div className="app-loader">
|
||||
<div className="opening">Opening</div>
|
||||
<div className="app-row">
|
||||
<AppDetails {...props.app} />
|
||||
</div>
|
||||
<button
|
||||
className="cancel"
|
||||
onClick={(): void => {
|
||||
props.setApp(undefined)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Apps are currently loading.
|
||||
if (!apps) {
|
||||
return (
|
||||
<div className="app-loader">
|
||||
<div className="loader">loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Apps have loaded.
|
||||
return (
|
||||
<>
|
||||
{apps.map((section, i) => (
|
||||
<AppList key={i} open={open} onKilled={refresh} {...section} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
export const Logo = (props: React.SVGProps<SVGSVGElement>): JSX.Element => (
|
||||
<svg
|
||||
className="logo"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 172 117"
|
||||
xmlSpace="preserve"
|
||||
fill="currentcolor"
|
||||
{...props}
|
||||
>
|
||||
<g>
|
||||
<g>
|
||||
<path
|
||||
className="st0"
|
||||
d="M164.8,50.9c-3.3,0-5.5-1.9-5.5-5.8V22.7c0-14.3-6-22.2-21.5-22.2h-7.2v15.1h2.2c6.1,0,9,3.3,9,9.2v19.8
|
||||
c0,8.6,2.6,12.1,8.3,13.9c-5.7,1.7-8.3,5.3-8.3,13.9c0,4.9,0,9.8,0,14.7c0,4.1,0,8.1-1.1,12.2c-1.1,3.8-2.9,7.4-5.4,10.5
|
||||
c-1.4,1.8-3,3.3-4.8,4.7v2h7.2c15.5,0,21.5-7.9,21.5-22.2V71.9c0-4,2.1-5.8,5.5-5.8h4.1V51h-4V50.9L164.8,50.9z"
|
||||
/>
|
||||
<path
|
||||
className="st0"
|
||||
d="M115.8,23.3H93.6c-0.5,0-0.9-0.4-0.9-0.9v-1.7c0-0.5,0.4-0.9,0.9-0.9h22.3c0.5,0,0.9,0.4,0.9,0.9v1.7
|
||||
C116.8,22.9,116.3,23.3,115.8,23.3z"
|
||||
/>
|
||||
<path
|
||||
className="st0"
|
||||
d="M119.6,44.9h-16.2c-0.5,0-0.9-0.4-0.9-0.9v-1.7c0-0.5,0.4-0.9,0.9-0.9h16.2c0.5,0,0.9,0.4,0.9,0.9V44
|
||||
C120.5,44.4,120.1,44.9,119.6,44.9z"
|
||||
/>
|
||||
<path
|
||||
className="st0"
|
||||
d="M126,34.1H93.6c-0.5,0-0.9-0.4-0.9-0.9v-1.7c0-0.5,0.4-0.9,0.9-0.9h32.3c0.5,0,0.9,0.4,0.9,0.9v1.7
|
||||
C126.8,33.6,126.5,34.1,126,34.1z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
className="st0"
|
||||
d="M67.9,28.2c2.2,0,4.4,0.2,6.5,0.7v-4.1c0-5.8,3-9.2,9-9.2h2.2V0.5h-7.2c-15.5,0-21.5,7.9-21.5,22.2v7.4
|
||||
C60.4,28.9,64.1,28.2,67.9,28.2z"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
className="st0"
|
||||
d="M132.8,82.6c-1.6-12.7-11.4-23.3-24-25.7c-3.5-0.7-7-0.8-10.4-0.2c-0.1,0-0.1-0.1-0.2-0.1
|
||||
c-5.5-11.5-17.3-19.1-30.1-19.1S43.6,44.9,38,56.4c-0.1,0-0.1,0.1-0.2,0.1c-3.6-0.4-7.2-0.2-10.8,0.7c-12.4,3-21.8,13.4-23.5,26
|
||||
c-0.2,1.3-0.3,2.6-0.3,3.8c0,3.8,2.6,7.3,6.4,7.8c4.7,0.7,8.8-2.9,8.7-7.5c0-0.7,0-1.5,0.1-2.2c0.8-6.4,5.7-11.8,12.1-13.3
|
||||
c2-0.5,4-0.6,5.9-0.3c6.1,0.8,12.1-2.3,14.7-7.7c1.9-4,4.9-7.5,8.9-9.4c4.4-2.1,9.4-2.4,14-0.8c4.8,1.7,8.4,5.3,10.6,9.8
|
||||
c2.3,4.4,3.4,7.5,8.3,8.1c2,0.3,7.6,0.2,9.7,0.1c4.1,0,8.2,1.4,11.1,4.3c1.9,2,3.3,4.5,3.9,7.3c0.9,4.5-0.2,9-2.9,12.4
|
||||
c-1.9,2.4-4.5,4.2-7.4,5c-1.4,0.4-2.8,0.5-4.2,0.5c-0.8,0-1.9,0-3.2,0c-4,0-12.5,0-18.9,0c-4.4,0-7.9-3.5-7.9-7.9V78.4V63.9
|
||||
c0-1.2-1-2.2-2.2-2.2h-3.1c-6.1,0.1-11,6.9-11,14.1s0,26.3,0,26.3c0,7.8,6.3,14.1,14.1,14.1c0,0,34.7-0.1,35.2-0.1
|
||||
c8-0.8,15.4-4.9,20.4-11.2C131.5,98.8,133.8,90.8,132.8,82.6z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
@ -1,152 +0,0 @@
|
||||
.modal-bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.animate > .modal-bar {
|
||||
transform: translateY(-100%);
|
||||
transition: transform 200ms;
|
||||
}
|
||||
|
||||
.animate.-show > .modal-bar {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-bar > .bar {
|
||||
background-color: #fcfcfc;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
box-sizing: border-box;
|
||||
color: #101010;
|
||||
display: flex;
|
||||
font-size: 0.8em;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
pointer-events: initial;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .close {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
top: 1px;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .open > .button {
|
||||
background-color: transparent;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #101010;
|
||||
color: #101010;
|
||||
cursor: pointer;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.modal-bar > .bar > .open > .button:hover {
|
||||
background-color: #bcc6fa;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 9999999;
|
||||
}
|
||||
|
||||
.modal-container > .modal {
|
||||
background: #fcfcfc;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
max-height: 400px;
|
||||
max-width: 664px;
|
||||
padding: 20px 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 145px;
|
||||
}
|
||||
|
||||
.sidebar-nav > .links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-nav > .links > .link {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.37);
|
||||
font-size: 1.4em;
|
||||
height: 31px;
|
||||
margin-bottom: 20px;
|
||||
padding: 0 35px;
|
||||
text-decoration: none;
|
||||
transition: 150ms color ease, 150ms height ease, 150ms margin-bottom ease;
|
||||
}
|
||||
|
||||
.sidebar-nav > .footer > .close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-nav > .links > .link[aria-current="page"],
|
||||
.sidebar-nav > .links > .link:hover,
|
||||
.sidebar-nav > .footer > .close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.modal-container > .modal > .sidebar-nav {
|
||||
border-right: 1.5px solid rgba(0, 0, 0, 0.37);
|
||||
}
|
@ -1,190 +0,0 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import * as React from "react"
|
||||
import { Application, isExecutableApplication } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { RequestError } from "../components/error"
|
||||
import { Browse } from "../pages/browse"
|
||||
import { Home } from "../pages/home"
|
||||
import { Login } from "../pages/login"
|
||||
import { Missing } from "../pages/missing"
|
||||
import { Open } from "../pages/open"
|
||||
import { Recent } from "../pages/recent"
|
||||
import { Animate } from "./animate"
|
||||
|
||||
export interface ModalProps {
|
||||
app?: Application
|
||||
authed: boolean
|
||||
error?: HttpError | Error | string
|
||||
setApp(app?: Application): void
|
||||
setError(error?: HttpError | Error | string): void
|
||||
}
|
||||
|
||||
enum Section {
|
||||
Browse,
|
||||
Home,
|
||||
Open,
|
||||
Recent,
|
||||
}
|
||||
|
||||
export const Modal: React.FunctionComponent<ModalProps> = (props) => {
|
||||
const [showModal, setShowModal] = React.useState<boolean>(false)
|
||||
const [showBar, setShowBar] = React.useState<boolean>(false) // TEMP: Will be true.
|
||||
const [section, setSection] = React.useState<Section>(Section.Home)
|
||||
|
||||
const setApp = (app: Application): void => {
|
||||
setShowModal(false)
|
||||
props.setApp(app)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
// Show the bar when hovering around the top area for a while.
|
||||
let timeout: NodeJS.Timeout | undefined
|
||||
const hover = (clientY: number): void => {
|
||||
if (clientY > 30 && timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
} else if (clientY <= 30 && !timeout) {
|
||||
// TEMP: No bar for now.
|
||||
// timeout = setTimeout(() => setShowBar(true), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const iframe =
|
||||
props.app && !isExecutableApplication(props.app) && (document.getElementById("iframe") as HTMLIFrameElement)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const postIframeMessage = (message: any): void => {
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(message, window.location.origin)
|
||||
} else {
|
||||
logger.warn("Tried to post message to missing iframe")
|
||||
}
|
||||
}
|
||||
|
||||
const onHover = (event: MouseEvent | MessageEvent): void => {
|
||||
hover((event as MessageEvent).data ? (event as MessageEvent).data.clientY : (event as MouseEvent).clientY)
|
||||
}
|
||||
|
||||
const onIframeLoaded = (): void => {
|
||||
if (props.app) {
|
||||
setApp({ ...props.app, loaded: true })
|
||||
}
|
||||
}
|
||||
|
||||
// No need to track the mouse if we don't have a hidden bar.
|
||||
const hasHiddenBar = !props.error && !showModal && props.app && !showBar
|
||||
|
||||
if (props.app && !isExecutableApplication(props.app)) {
|
||||
// Once the iframe reports it has loaded, tell it to bind mousemove and
|
||||
// start listening for that instead.
|
||||
if (!props.app.loaded) {
|
||||
window.addEventListener("message", onIframeLoaded)
|
||||
} else if (hasHiddenBar) {
|
||||
postIframeMessage({ bind: "mousemove", prop: "clientY" })
|
||||
window.removeEventListener("message", onIframeLoaded)
|
||||
window.addEventListener("message", onHover)
|
||||
}
|
||||
} else if (hasHiddenBar) {
|
||||
document.addEventListener("mousemove", onHover)
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
document.removeEventListener("mousemove", onHover)
|
||||
window.removeEventListener("message", onHover)
|
||||
window.removeEventListener("message", onIframeLoaded)
|
||||
if (props.app && !isExecutableApplication(props.app)) {
|
||||
postIframeMessage({ unbind: "mousemove" })
|
||||
}
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [showBar, props.error, showModal, props.app])
|
||||
|
||||
const content = (): React.ReactElement => {
|
||||
if (!props.authed) {
|
||||
return <Login setApp={setApp} />
|
||||
}
|
||||
switch (section) {
|
||||
case Section.Recent:
|
||||
return <Recent app={props.app} setApp={setApp} />
|
||||
case Section.Home:
|
||||
return <Home app={props.app} />
|
||||
case Section.Browse:
|
||||
return <Browse />
|
||||
case Section.Open:
|
||||
return <Open app={props.app} setApp={setApp} />
|
||||
default:
|
||||
return <Missing />
|
||||
}
|
||||
}
|
||||
|
||||
return props.error || showModal || !props.app || !props.app.loaded ? (
|
||||
<div className="modal-container">
|
||||
<div className="modal">
|
||||
{props.authed && (!props.app || props.app.loaded) ? (
|
||||
<aside className="sidebar-nav">
|
||||
<nav className="links">
|
||||
{props.authed ? (
|
||||
<>
|
||||
<button className="link" onClick={(): void => setSection(Section.Recent)}>
|
||||
Recent
|
||||
</button>
|
||||
<button className="link" onClick={(): void => setSection(Section.Open)}>
|
||||
Open
|
||||
</button>
|
||||
<button className="link" onClick={(): void => setSection(Section.Browse)}>
|
||||
Browse
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</nav>
|
||||
<div className="footer">
|
||||
{props.app && props.app.loaded && !props.error ? (
|
||||
<button className="close" onClick={(): void => setShowModal(false)}>
|
||||
Close
|
||||
</button>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
) : (
|
||||
undefined
|
||||
)}
|
||||
{props.error ? (
|
||||
<RequestError
|
||||
error={props.error}
|
||||
onClose={(): void => {
|
||||
props.setApp(undefined)
|
||||
props.setError(undefined)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="content">{content()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Animate show={showBar} delay={200}>
|
||||
<div className="modal-bar">
|
||||
<div className="bar">
|
||||
<div className="content">
|
||||
<div className="help">
|
||||
Hover at the top {/*or press <strong>Ctrl+Shift+G</strong>*/} to display this menu.
|
||||
</div>
|
||||
</div>
|
||||
<div className="open">
|
||||
<button className="button" onClick={(): void => setShowModal(true)}>
|
||||
Open Modal
|
||||
</button>
|
||||
</div>
|
||||
<button className="close" onClick={(): void => setShowBar(false)}>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Animate>
|
||||
)
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as ReactDOM from "react-dom"
|
||||
import App from "./app"
|
||||
import { getOptions } from "../common/util"
|
||||
|
||||
import "./app.css"
|
||||
import "./pages/home.css"
|
||||
import "./pages/login.css"
|
||||
import "./pages/missing.css"
|
||||
import "./components/error.css"
|
||||
import "./components/list.css"
|
||||
import "./components/modal.css"
|
||||
|
||||
ReactDOM.hydrate(<App options={getOptions()} />, document.getElementById("root"))
|
24
src/browser/pages/app.css
Normal file
24
src/browser/pages/app.css
Normal file
@ -0,0 +1,24 @@
|
||||
/* NOTE: Disable scrollbars since an oversized element creates them. */
|
||||
.app-input {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
outline: none;
|
||||
position: fixed;
|
||||
scrollbar-width: none;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.app-input::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-render {
|
||||
height: 100%;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
@ -3,17 +3,15 @@
|
||||
<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' fonts.googleapis.com; script-src 'self' 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;">
|
||||
<title>code-server</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="style-src 'self'; manifest-src 'self'; img-src 'self' data:;">
|
||||
<title>code-server — {{APP_NAME}}</title>
|
||||
<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 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="{{BASE}}/static-{{COMMIT}}/dist/index.css" rel="stylesheet">
|
||||
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet">
|
||||
<meta id="coder-options" data-settings="{{OPTIONS}}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">{{COMPONENT}}</div>
|
||||
<script src="{{BASE}}/static-{{COMMIT}}/dist/index.js"></script>
|
||||
<script src="{{BASE}}/static-{{COMMIT}}/dist/app.js"></script>
|
||||
</body>
|
||||
</html>
|
14
src/browser/pages/app.ts
Normal file
14
src/browser/pages/app.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { getOptions } from "../../common/util"
|
||||
|
||||
import "./app.css"
|
||||
import "./error.css"
|
||||
import "./global.css"
|
||||
import "./home.css"
|
||||
import "./login.css"
|
||||
|
||||
const options = getOptions()
|
||||
const parts = window.location.pathname.replace(/^\//g, "").split("/")
|
||||
parts[parts.length - 1] = options.base
|
||||
const url = new URL(window.location.origin + "/" + parts.join("/"))
|
||||
|
||||
console.log(url)
|
@ -1,33 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { FilesResponse } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { getFiles } from "../api"
|
||||
import { RequestError } from "../components/error"
|
||||
|
||||
/**
|
||||
* File browser.
|
||||
*/
|
||||
export const Browse: React.FunctionComponent = (props) => {
|
||||
const [response, setResponse] = React.useState<FilesResponse>()
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
React.useEffect(() => {
|
||||
getFiles()
|
||||
.then(setResponse)
|
||||
.catch(setError)
|
||||
}, [props])
|
||||
|
||||
return (
|
||||
<>
|
||||
{error || (response && response.files.length === 0) ? (
|
||||
<RequestError error={error || "Empty directory"} />
|
||||
) : (
|
||||
<ul>
|
||||
{((response && response.files) || []).map((f, i) => (
|
||||
<li key={i}>{f.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
20
src/browser/pages/error.css
Normal file
20
src/browser/pages/error.css
Normal file
@ -0,0 +1,20 @@
|
||||
.error-display {
|
||||
box-sizing: border-box;
|
||||
color: #fcfcfc;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-display > .links {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.error-display > .links > .link {
|
||||
color: #b6b6b6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.error-display > .links > .link:hover {
|
||||
color: #fcfcfc;
|
||||
text-decoration: underline;
|
||||
}
|
26
src/browser/pages/error.html
Normal file
26
src/browser/pages/error.html
Normal file
@ -0,0 +1,26 @@
|
||||
<!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="style-src 'self'; manifest-src 'self'; img-src 'self' data:;">
|
||||
<title>code-server {{ERROR_TITLE}}</title>
|
||||
<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 rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
|
||||
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center-container">
|
||||
<div class="error-display">
|
||||
<h2 class="header">{{ERROR_HEADER}}</h2>
|
||||
<div class="body">
|
||||
{{ERROR_BODY}}
|
||||
</div>
|
||||
<div class="links">
|
||||
<a class="link" href="{{BASE}}">go home</a
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -1,20 +1,16 @@
|
||||
html,
|
||||
body,
|
||||
#root,
|
||||
iframe {
|
||||
.center-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #272727;
|
||||
color: #f4f4f4;
|
||||
margin: 0;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@ -22,20 +18,10 @@ button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.coder-splash {
|
||||
.center-container {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.coder-splash > .logo {
|
||||
color: rgba(255, 255, 255, 0.03);
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
@ -1,8 +1,70 @@
|
||||
.orientation-guide {
|
||||
align-items: center;
|
||||
.app-lists {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-list > .header {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.app-list > .none {
|
||||
color: #b6b6b6;
|
||||
}
|
||||
|
||||
.app-list + .app-list {
|
||||
border-top: 1px solid #666;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.app-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.app-row > .open {
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-row > .open:hover {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
||||
.app-row > .open > .icon {
|
||||
height: 1rem;
|
||||
margin-right: 5px;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.app-row > .open > .icon.-missing {
|
||||
background-color: #eee;
|
||||
color: #b6b6b6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-row > .open > .icon.-missing::after {
|
||||
content: "?";
|
||||
font-size: 0.7rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.kill-form {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.kill-form > .kill {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
color: #b6b6b6;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.kill-form > .kill:hover {
|
||||
color: #fafafa;
|
||||
}
|
||||
|
34
src/browser/pages/home.html
Normal file
34
src/browser/pages/home.html
Normal file
@ -0,0 +1,34 @@
|
||||
<!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="style-src 'self'; manifest-src 'self'; img-src 'self' data:;">
|
||||
<title>code-server</title>
|
||||
<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 rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
|
||||
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet">
|
||||
<meta id="coder-options" data-settings="{{OPTIONS}}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center-container">
|
||||
<div class="app-lists">
|
||||
<div class="app-list">
|
||||
<h2 class="header">Running Applications</h2>
|
||||
{{APP_LIST:RUNNING}}
|
||||
</div>
|
||||
|
||||
<div class="app-list">
|
||||
<h2 class="header">Editors</h2>
|
||||
{{APP_LIST:EDITORS}}
|
||||
</div>
|
||||
|
||||
<div class="app-list">
|
||||
<h2 class="header">Other</h2>
|
||||
{{APP_LIST:OTHER}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
@ -1,22 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../../common/api"
|
||||
import { authenticate, setAuthed } from "../api"
|
||||
|
||||
export interface HomeProps {
|
||||
app?: Application
|
||||
}
|
||||
|
||||
export const Home: React.FunctionComponent<HomeProps> = (props) => {
|
||||
React.useEffect(() => {
|
||||
authenticate()
|
||||
.then(() => setAuthed(true))
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="orientation-guide">
|
||||
<div className="welcome">Welcome to code-server.</div>
|
||||
{props.app && !props.app.loaded ? <div className="loader">loading...</div> : undefined}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,10 +1,25 @@
|
||||
.login-form {
|
||||
align-items: center;
|
||||
background: #fcfcfc;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
|
||||
color: rgba(0, 0, 0, 0.37);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
max-height: 400px;
|
||||
max-width: 664px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-form > .header {
|
||||
align-items: center;
|
||||
color: #b6b6b6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-form > .field {
|
||||
@ -13,18 +28,19 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-form > .field-error {
|
||||
margin-top: 1em;
|
||||
.login-form > .error {
|
||||
color: red;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-form > .field > .password {
|
||||
border: 1px solid #b6b6b6;
|
||||
box-sizing: border-box;
|
||||
padding: 1em;
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-form > .field > .user {
|
||||
.login-form > .user {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -33,11 +49,5 @@
|
||||
border: 1px solid #b6b6b6;
|
||||
box-sizing: border-box;
|
||||
margin-left: -1px;
|
||||
padding: 1em 2em;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
align-items: center;
|
||||
color: #b6b6b6;
|
||||
margin-bottom: 1em;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
48
src/browser/pages/login.html
Normal file
48
src/browser/pages/login.html
Normal file
@ -0,0 +1,48 @@
|
||||
<!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="style-src 'self'; script-src 'unsafe-inline'; manifest-src 'self'; img-src 'self' data:;">
|
||||
<title>code-server login</title>
|
||||
<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 rel="apple-touch-icon" href="{{BASE}}/static-{{COMMIT}}/src/browser/media/code-server.png" />
|
||||
<link href="{{BASE}}/static-{{COMMIT}}/dist/app.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="center-container">
|
||||
<form class="login-form" method="post">
|
||||
<div class="header">
|
||||
<div class="main">Welcome to code-server</div>
|
||||
<div class="sub">Please log in below</div>
|
||||
</div>
|
||||
<input class="user" type="text" autocomplete="username" />
|
||||
<input id="base" type="hidden" name="base" value="/" />
|
||||
<div class="field">
|
||||
<!-- The onfocus code places the cursor at the end of the value. -->
|
||||
<input
|
||||
required autofocus
|
||||
value="{{VALUE}}"
|
||||
onfocus="const value=this.value;this.value='';this.value=value;"
|
||||
class="password"
|
||||
type="password"
|
||||
placeholder="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button class="submit" type="submit">
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
{{ERROR}}
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
<script>
|
||||
const parts = window.location.pathname.replace(/^\//g, "").split("/")
|
||||
parts[parts.length - 1] = "{{BASE}}"
|
||||
const url = new URL(window.location.origin + "/" + parts.join("/"))
|
||||
document.getElementById("base").value = url.pathname
|
||||
</script>
|
||||
</html>
|
@ -1,59 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../../common/api"
|
||||
import { HttpError } from "../../common/http"
|
||||
import { authenticate, setAuthed } from "../api"
|
||||
import { FieldError } from "../components/error"
|
||||
|
||||
export interface LoginProps {
|
||||
setApp(app: Application): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Login page. Will redirect on success.
|
||||
*/
|
||||
export const Login: React.FunctionComponent<LoginProps> = (props) => {
|
||||
const [password, setPassword] = React.useState<string>("")
|
||||
const [error, setError] = React.useState<HttpError>()
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault()
|
||||
authenticate({ password })
|
||||
.then((response) => {
|
||||
if (response.app) {
|
||||
props.setApp(response.app)
|
||||
}
|
||||
setAuthed(true)
|
||||
})
|
||||
.catch(setError)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
authenticate()
|
||||
.then(() => setAuthed(true))
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<div className="login-header">
|
||||
<div className="main">Welcome to code-server</div>
|
||||
<div className="sub">Please log in below</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<input className="user" type="text" autoComplete="username" />
|
||||
<input
|
||||
autoFocus
|
||||
className="password"
|
||||
type="password"
|
||||
placeholder="password"
|
||||
autoComplete="current-password"
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => setPassword(event.target.value)}
|
||||
/>
|
||||
<button className="submit" type="submit">
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
{error ? <FieldError error={error} /> : undefined}
|
||||
</form>
|
||||
)
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
.missing-message {
|
||||
align-items: center;
|
||||
color: #b6b6b6;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
export const Missing: React.FunctionComponent = () => {
|
||||
return (
|
||||
<div className="missing-message">
|
||||
<div className="message">404</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../../common/api"
|
||||
import { getApplications } from "../api"
|
||||
import { ApplicationSection, AppLoader } from "../components/list"
|
||||
|
||||
export interface OpenProps {
|
||||
app?: Application
|
||||
setApp(app: Application): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Display recently used applications.
|
||||
*/
|
||||
export const Open: React.FunctionComponent<OpenProps> = (props) => {
|
||||
return (
|
||||
<AppLoader
|
||||
getApps={async (): Promise<ReadonlyArray<ApplicationSection>> => {
|
||||
const response = await getApplications()
|
||||
return [
|
||||
{
|
||||
header: "Applications",
|
||||
apps: response && response.applications,
|
||||
},
|
||||
]
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Application } from "../../common/api"
|
||||
import { getRecent } from "../api"
|
||||
import { ApplicationSection, AppLoader } from "../components/list"
|
||||
|
||||
export interface RecentProps {
|
||||
app?: Application
|
||||
setApp(app: Application): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Display recently used applications.
|
||||
*/
|
||||
export const Recent: React.FunctionComponent<RecentProps> = (props) => {
|
||||
return (
|
||||
<AppLoader
|
||||
getApps={async (): Promise<ReadonlyArray<ApplicationSection>> => {
|
||||
const response = await getRecent()
|
||||
return [
|
||||
{
|
||||
header: "Running Applications",
|
||||
apps: response && response.running,
|
||||
},
|
||||
{
|
||||
header: "Recent Applications",
|
||||
apps: response && response.recent,
|
||||
},
|
||||
]
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
@ -4,6 +4,8 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<meta http-equiv="Content-Security-Policy" content="font-src 'self'; connect-src 'self' https:; default-src ws: wss: 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; manifest-src 'self'; img-src 'self' data:;">
|
||||
|
||||
<!-- 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">
|
||||
|
||||
@ -12,8 +14,6 @@
|
||||
|
||||
<!-- 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}}">
|
||||
@ -21,12 +21,16 @@
|
||||
<!-- 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">
|
||||
<!-- PROD_ONLY
|
||||
<link data-name="vs/workbench/workbench.web.api" rel="stylesheet" href="{{VS_BASE}}/static-{{COMMIT}}/out/vs/workbench/workbench.web.api.css">
|
||||
END_PROD_ONLY -->
|
||||
<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">
|
||||
<!-- PROD_ONLY
|
||||
<link rel="prefetch" href="{{VS_BASE}}/static-{{COMMIT}}/node_modules/semver-umd/lib/semver-umd.js">
|
||||
END_PROD_ONLY -->
|
||||
</head>
|
||||
|
||||
<body aria-label="">
|
||||
@ -78,12 +82,10 @@
|
||||
};
|
||||
</script>
|
||||
<script src="{{VS_BASE}}/static-{{COMMIT}}/out/vs/loader.js"></script>
|
||||
<!-- PROD_ONLY
|
||||
<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> -->
|
||||
END_PROD_ONLY -->
|
||||
<script>
|
||||
require(['vs/code/browser/workbench/workbench'], function() {});
|
||||
</script>
|
@ -1,13 +1,15 @@
|
||||
export interface Application {
|
||||
readonly categories?: string[]
|
||||
readonly comment?: string
|
||||
readonly directory?: string
|
||||
readonly embedPath?: string
|
||||
readonly exec?: string
|
||||
readonly genericName?: string
|
||||
readonly icon?: string
|
||||
readonly loaded?: boolean
|
||||
readonly installed?: boolean
|
||||
readonly name: string
|
||||
readonly path: string
|
||||
readonly path?: string
|
||||
readonly sessionId?: string
|
||||
readonly version?: string
|
||||
}
|
||||
|
||||
export interface ApplicationsResponse {
|
||||
@ -22,52 +24,17 @@ export enum SessionError {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
basePath: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
export interface SessionResponse {
|
||||
/**
|
||||
* An application to load immediately after logging in.
|
||||
* Whether the session was created or an existing one was returned.
|
||||
*/
|
||||
app?: Application
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
created: boolean
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export interface ExecutableApplication extends Application {
|
||||
exec: string
|
||||
}
|
||||
|
||||
export const isExecutableApplication = (app: Application): app is ExecutableApplication => {
|
||||
return !!(app as ExecutableApplication).exec
|
||||
}
|
||||
|
||||
export interface RunningApplication extends ExecutableApplication {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export const isRunningApplication = (app: Application): app is RunningApplication => {
|
||||
return !!(app as RunningApplication).sessionId
|
||||
}
|
||||
|
||||
export interface RecentResponse {
|
||||
readonly recent: ReadonlyArray<Application>
|
||||
readonly running: ReadonlyArray<RunningApplication>
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
readonly type: "file" | "directory"
|
||||
readonly name: string
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
export interface FilesResponse {
|
||||
files: FileEntry[]
|
||||
readonly running: ReadonlyArray<Application>
|
||||
}
|
||||
|
||||
export interface HealthRequest {
|
||||
|
@ -17,8 +17,8 @@ export class HttpError extends Error {
|
||||
|
||||
export enum ApiEndpoint {
|
||||
applications = "/applications",
|
||||
files = "/files",
|
||||
login = "/login",
|
||||
recent = "/recent",
|
||||
run = "/run",
|
||||
session = "/session",
|
||||
status = "/status",
|
||||
}
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { logger } from "@coder/logger"
|
||||
import { Application } from "../common/api"
|
||||
|
||||
export interface Options {
|
||||
app?: Application
|
||||
authed: boolean
|
||||
basePath: string
|
||||
base: string
|
||||
logLevel: number
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
304
src/node/app/api.ts
Normal 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
130
src/node/app/app.ts
Normal 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
27
src/node/app/bin.ts
Normal 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
124
src/node/app/login.ts
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
@ -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())
|
||||
|
||||
|
113
src/node/http.ts
113
src/node/http.ts
@ -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()
|
||||
|
@ -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>
|
@ -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>
|
180
yarn.lock
180
yarn.lock
@ -881,14 +881,6 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/hookrouter@^2.2.1":
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/hookrouter/-/hookrouter-2.2.1.tgz#0c69e671957b48ade9e042612faf3fc833a3fd59"
|
||||
integrity sha512-C4Ae6yf8vE4TEKZa0EpP2o85UMVHKsZeJwT0oWiQ1QkNBbkQ8bk5A+qWS25Uect9r1Uivz/dnSg6v4WwzFjgrg==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
csstype "^2.2.0"
|
||||
|
||||
"@types/json-schema@^7.0.3":
|
||||
version "7.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
|
||||
@ -933,11 +925,6 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/prop-types@*":
|
||||
version "15.7.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
|
||||
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
|
||||
|
||||
"@types/q@^1.5.1":
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
|
||||
@ -948,21 +935,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
|
||||
integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
|
||||
|
||||
"@types/react-dom@^16.9.5":
|
||||
version "16.9.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.5.tgz#5de610b04a35d07ffd8f44edad93a71032d9aaa7"
|
||||
integrity sha512-BX6RQ8s9D+2/gDhxrj8OW+YD4R+8hj7FEM/OJHGNR0KipE1h1mSsf39YeyC81qafkq+N3rU3h3RFbLSwE5VqUg==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react@*", "@types/react@^16.9.18":
|
||||
version "16.9.19"
|
||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.19.tgz#c842aa83ea490007d29938146ff2e4d9e4360c40"
|
||||
integrity sha512-LJV97//H+zqKWMms0kvxaKYJDG05U2TtQB3chRLF8MPNs+MQh/H1aGlyDUxjaHvu08EAGerdX2z4LTBc7ns77A==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
csstype "^2.2.0"
|
||||
|
||||
"@types/safe-compare@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/safe-compare/-/safe-compare-1.1.0.tgz#47ed9b9ca51a3a791b431cd59b28f47fa9bf1224"
|
||||
@ -1228,7 +1200,7 @@ array-equal@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93"
|
||||
integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=
|
||||
|
||||
array-includes@^3.0.3, array-includes@^3.1.1:
|
||||
array-includes@^3.0.3:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348"
|
||||
integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==
|
||||
@ -2348,11 +2320,6 @@ cssstyle@^1.1.1:
|
||||
dependencies:
|
||||
cssom "0.3.x"
|
||||
|
||||
csstype@^2.2.0:
|
||||
version "2.6.8"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.8.tgz#0fb6fc2417ffd2816a418c9336da74d7f07db431"
|
||||
integrity sha512-msVS9qTuMT5zwAGCVm4mxfrZ18BNc6Csd0oJAtiFMZ1FAx1CCvy2+5MDmYoix63LM/6NDbNtodCiGYGmFgO0dA==
|
||||
|
||||
dash-ast@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/dash-ast/-/dash-ast-1.0.0.tgz#12029ba5fb2f8aa6f0a861795b23c1b4b6c27d37"
|
||||
@ -2543,13 +2510,6 @@ doctrine@1.5.0:
|
||||
esutils "^2.0.2"
|
||||
isarray "^1.0.0"
|
||||
|
||||
doctrine@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
|
||||
integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
doctrine@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
|
||||
@ -2831,27 +2791,6 @@ eslint-plugin-prettier@^3.1.0:
|
||||
dependencies:
|
||||
prettier-linter-helpers "^1.0.0"
|
||||
|
||||
eslint-plugin-react-hooks@^1.7.0:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.7.0.tgz#6210b6d5a37205f0b92858f895a4e827020a7d04"
|
||||
integrity sha512-iXTCFcOmlWvw4+TOE8CLWj6yX1GwzT0Y6cUfHHZqWnSk144VmVIRcVGtUAzrLES7C798lmvnt02C7rxaOX1HNA==
|
||||
|
||||
eslint-plugin-react@^7.14.3:
|
||||
version "7.18.3"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.18.3.tgz#8be671b7f6be095098e79d27ac32f9580f599bc8"
|
||||
integrity sha512-Bt56LNHAQCoou88s8ViKRjMB2+36XRejCQ1VoLj716KI1MoE99HpTVvIThJ0rvFmG4E4Gsq+UgToEjn+j044Bg==
|
||||
dependencies:
|
||||
array-includes "^3.1.1"
|
||||
doctrine "^2.1.0"
|
||||
has "^1.0.3"
|
||||
jsx-ast-utils "^2.2.3"
|
||||
object.entries "^1.1.1"
|
||||
object.fromentries "^2.0.2"
|
||||
object.values "^1.1.1"
|
||||
prop-types "^15.7.2"
|
||||
resolve "^1.14.2"
|
||||
string.prototype.matchall "^4.0.2"
|
||||
|
||||
eslint-scope@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9"
|
||||
@ -3564,11 +3503,6 @@ hmac-drbg@^1.0.0:
|
||||
minimalistic-assert "^1.0.0"
|
||||
minimalistic-crypto-utils "^1.0.1"
|
||||
|
||||
hookrouter@^1.2.3:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/hookrouter/-/hookrouter-1.2.3.tgz#a65599a1be376b51734caf7c4f7f8aba59bb2c77"
|
||||
integrity sha512-n0mqEBGgXIxYRNMHlwCzoyTOk0OB5Es3jwUyA3+2l5nte/52n0CMMj1bmoCabC8K43YTUEr0zzexTBfo//tq2Q==
|
||||
|
||||
hosted-git-info@^2.1.4:
|
||||
version "2.8.5"
|
||||
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c"
|
||||
@ -3800,15 +3734,6 @@ insert-module-globals@^7.0.0:
|
||||
undeclared-identifiers "^1.1.2"
|
||||
xtend "^4.0.0"
|
||||
|
||||
internal-slot@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.2.tgz#9c2e9fb3cd8e5e4256c6f45fe310067fcfa378a3"
|
||||
integrity sha512-2cQNfwhAfJIkU4KZPkDI+Gj5yNNnbqi40W9Gge6dfnk4TocEVm00B3bdiL+JINrbGJil2TeHvM4rETGzk/f/0g==
|
||||
dependencies:
|
||||
es-abstract "^1.17.0-next.1"
|
||||
has "^1.0.3"
|
||||
side-channel "^1.0.2"
|
||||
|
||||
invariant@^2.2.2, invariant@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||
@ -4267,14 +4192,6 @@ jsprim@^1.2.2:
|
||||
json-schema "0.2.3"
|
||||
verror "1.10.0"
|
||||
|
||||
jsx-ast-utils@^2.2.3:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-2.2.3.tgz#8a9364e402448a3ce7f14d357738310d9248054f"
|
||||
integrity sha512-EdIHFMm+1BPynpKOpdPqiOsvnIrInRGJD7bzPZdPkjitQEqpdpUuFpq4T0npZFKTiB3RhWFdGN+oqOJIdhDhQA==
|
||||
dependencies:
|
||||
array-includes "^3.0.3"
|
||||
object.assign "^4.1.0"
|
||||
|
||||
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
|
||||
@ -4428,7 +4345,7 @@ longest-streak@^2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4"
|
||||
integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==
|
||||
|
||||
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||
loose-envify@^1.0.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
|
||||
@ -4936,26 +4853,6 @@ object.assign@4.1.0, object.assign@^4.1.0:
|
||||
has-symbols "^1.0.0"
|
||||
object-keys "^1.0.11"
|
||||
|
||||
object.entries@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.1.tgz#ee1cf04153de02bb093fec33683900f57ce5399b"
|
||||
integrity sha512-ilqR7BgdyZetJutmDPfXCDffGa0/Yzl2ivVNpbx/g4UeWrCdRnFDUBrKJGLhGieRHDATnyZXWBeCb29k9CJysQ==
|
||||
dependencies:
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.17.0-next.1"
|
||||
function-bind "^1.1.1"
|
||||
has "^1.0.3"
|
||||
|
||||
object.fromentries@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.2.tgz#4a09c9b9bb3843dd0f89acdb517a794d4f355ac9"
|
||||
integrity sha512-r3ZiBH7MQppDJVLx6fhD618GKNG40CZYH9wgwdhKxBDDbQgjeWGGd4AtkZad84d291YxvWe7bJGuE65Anh0dxQ==
|
||||
dependencies:
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.17.0-next.1"
|
||||
function-bind "^1.1.1"
|
||||
has "^1.0.3"
|
||||
|
||||
object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649"
|
||||
@ -4971,7 +4868,7 @@ object.pick@^1.3.0:
|
||||
dependencies:
|
||||
isobject "^3.0.1"
|
||||
|
||||
object.values@^1.1.0, object.values@^1.1.1:
|
||||
object.values@^1.1.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e"
|
||||
integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==
|
||||
@ -5859,15 +5756,6 @@ progress@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
|
||||
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
|
||||
|
||||
prop-types@^15.6.2, prop-types@^15.7.2:
|
||||
version "15.7.2"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
|
||||
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
|
||||
dependencies:
|
||||
loose-envify "^1.4.0"
|
||||
object-assign "^4.1.1"
|
||||
react-is "^16.8.1"
|
||||
|
||||
psl@^1.1.24, psl@^1.1.28:
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c"
|
||||
@ -5972,30 +5860,6 @@ range-parser@~1.2.1:
|
||||
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
|
||||
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
|
||||
|
||||
react-dom@^16.12.0:
|
||||
version "16.12.0"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.12.0.tgz#0da4b714b8d13c2038c9396b54a92baea633fe11"
|
||||
integrity sha512-LMxFfAGrcS3kETtQaCkTKjMiifahaMySFDn71fZUNpPHZQEzmk/GiAeIT8JSOrHB23fnuCOMruL2a8NYlw+8Gw==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.2"
|
||||
scheduler "^0.18.0"
|
||||
|
||||
react-is@^16.8.1:
|
||||
version "16.12.0"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c"
|
||||
integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==
|
||||
|
||||
react@^16.12.0:
|
||||
version "16.12.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83"
|
||||
integrity sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
read-only-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/read-only-stream/-/read-only-stream-2.0.0.tgz#2724fd6a8113d73764ac288d4386270c1dbf17f0"
|
||||
@ -6124,14 +5988,6 @@ regex-not@^1.0.0, regex-not@^1.0.2:
|
||||
extend-shallow "^3.0.2"
|
||||
safe-regex "^1.1.0"
|
||||
|
||||
regexp.prototype.flags@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75"
|
||||
integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==
|
||||
dependencies:
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.17.0-next.1"
|
||||
|
||||
regexpp@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
|
||||
@ -6334,7 +6190,7 @@ resolve@1.1.7:
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b"
|
||||
integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=
|
||||
|
||||
resolve@^1.1.4, resolve@^1.1.5, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.14.2, resolve@^1.3.2:
|
||||
resolve@^1.1.4, resolve@^1.1.5, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.3.2:
|
||||
version "1.15.0"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5"
|
||||
integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw==
|
||||
@ -6461,14 +6317,6 @@ saxes@^3.1.9:
|
||||
dependencies:
|
||||
xmlchars "^2.1.1"
|
||||
|
||||
scheduler@^0.18.0:
|
||||
version "0.18.0"
|
||||
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.18.0.tgz#5901ad6659bc1d8f3fdaf36eb7a67b0d6746b1c4"
|
||||
integrity sha512-agTSHR1Nbfi6ulI0kYNK0203joW2Y5W4po4l+v03tOoiJKpTBbxpNhWDvqc/4IcOw+KLmSiQLTasZ4cab2/UWQ==
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
object-assign "^4.1.1"
|
||||
|
||||
"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.7.0:
|
||||
version "5.7.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
|
||||
@ -6588,14 +6436,6 @@ shell-quote@^1.6.1:
|
||||
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.2.tgz#67a7d02c76c9da24f99d20808fcaded0e0e04be2"
|
||||
integrity sha512-mRz/m/JVscCrkMyPqHc/bczi3OQHkLTqXHEFu0zDhK/qfv3UcOA4SVmRCLmos4bhjr9ekVQubj/R7waKapmiQg==
|
||||
|
||||
side-channel@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.2.tgz#df5d1abadb4e4bf4af1cd8852bf132d2f7876947"
|
||||
integrity sha512-7rL9YlPHg7Ancea1S96Pa8/QWb4BtXL/TZvS6B8XFetGBeuhAsfmUspK6DokBeZ64+Kj9TCNRD/30pVz1BvQNA==
|
||||
dependencies:
|
||||
es-abstract "^1.17.0-next.1"
|
||||
object-inspect "^1.7.0"
|
||||
|
||||
signal-exit@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
|
||||
@ -6875,18 +6715,6 @@ string-width@^4.1.0, string-width@^4.2.0:
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
string.prototype.matchall@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.2.tgz#48bb510326fb9fdeb6a33ceaa81a6ea04ef7648e"
|
||||
integrity sha512-N/jp6O5fMf9os0JU3E72Qhf590RSRZU/ungsL/qJUYVTNv7hTG0P/dbPjxINVN9jpscu3nzYwKESU3P3RY5tOg==
|
||||
dependencies:
|
||||
define-properties "^1.1.3"
|
||||
es-abstract "^1.17.0"
|
||||
has-symbols "^1.0.1"
|
||||
internal-slot "^1.0.2"
|
||||
regexp.prototype.flags "^1.3.0"
|
||||
side-channel "^1.0.2"
|
||||
|
||||
string.prototype.trimleft@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.1.tgz#9bdb8ac6abd6d602b17a4ed321870d2f8dcefc74"
|
||||
|
Reference in New Issue
Block a user