diff --git a/.eslintrc.yaml b/.eslintrc.yaml index fbd92b97..fbb9ac0e 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -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. diff --git a/package.json b/package.json index bd51606b..73f29e70 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build.ts b/scripts/build.ts index 4088431b..937dc8cb 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -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, diff --git a/scripts/code-server.sh b/scripts/code-server.sh new file mode 100755 index 00000000..64066fbd --- /dev/null +++ b/scripts/code-server.sh @@ -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 "$@" diff --git a/src/browser/api.ts b/src/browser/api.ts deleted file mode 100644 index 8186bb77..00000000 --- a/src/browser/api.ts +++ /dev/null @@ -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 => { - 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 => { - 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 => { - const response = await tryRequest(ApiEndpoint.files) - return response.json() -} - -export const getRecent = async (): Promise => { - const response = await tryRequest(ApiEndpoint.recent) - return response.json() -} - -export const getApplications = async (): Promise => { - const response = await tryRequest(ApiEndpoint.applications) - return response.json() -} - -export const getSession = async (app: Application): Promise => { - const response = await tryRequest(ApiEndpoint.session, { - method: "POST", - body: JSON.stringify(app), - }) - return response.json() -} - -export const killSession = async (app: Application): Promise => { - return tryRequest(ApiEndpoint.session, { - method: "DELETE", - body: JSON.stringify(app), - }) -} diff --git a/src/browser/app.tsx b/src/browser/app.tsx deleted file mode 100644 index 16f639ed..00000000 --- a/src/browser/app.tsx +++ /dev/null @@ -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 = (props) => { - const [authed, setAuthed] = React.useState(props.options.authed) - const [app, setApp] = React.useState(props.options.app) - const [error, setError] = React.useState() - - 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 ? ( -
- -
- ) : ( - undefined - )} - - {authed && app && app.embedPath && app.redirected ? ( - - ) : ( - undefined - )} - - ) -} - -export default App diff --git a/src/browser/components/animate.tsx b/src/browser/components/animate.tsx deleted file mode 100644 index fa866ea7..00000000 --- a/src/browser/components/animate.tsx +++ /dev/null @@ -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 = (props) => { - const [timer, setTimer] = React.useState() - const [mount, setMount] = React.useState(false) - const [visible, setVisible] = React.useState(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 ?
{props.children}
: null -} diff --git a/src/browser/components/error.css b/src/browser/components/error.css deleted file mode 100644 index 81a1a763..00000000 --- a/src/browser/components/error.css +++ /dev/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; -} diff --git a/src/browser/components/error.tsx b/src/browser/components/error.tsx deleted file mode 100644 index d8326d0a..00000000 --- a/src/browser/components/error.tsx +++ /dev/null @@ -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 = (props) => { - return ( -
-
{typeof props.error === "string" ? props.error : props.error.message}
- {props.onClose ? ( - - ) : ( - undefined - )} -
- ) -} - -/** - * 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 = (props) => { - return
{humanizeFormError(props.error)}
-} diff --git a/src/browser/components/list.css b/src/browser/components/list.css deleted file mode 100644 index bc148fe6..00000000 --- a/src/browser/components/list.css +++ /dev/null @@ -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; -} diff --git a/src/browser/components/list.tsx b/src/browser/components/list.tsx deleted file mode 100644 index 5dfa32ce..00000000 --- a/src/browser/components/list.tsx +++ /dev/null @@ -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 = (props) => { - return ( - <> - {props.icon ? ( - - ) : ( -
- )} -
{props.name}
- - ) -} - -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 = (props) => { - const [killing, setKilling] = React.useState(false) - const [error, setError] = React.useState() - - 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 ( -
- - {isRunningApplication(props.app) && !killing ? ( - - ) : ( - undefined - )} -
- ) -} - -export interface AppListProps { - readonly header: string - readonly apps?: ReadonlyArray - 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 = (props) => { - return ( -
-

{props.header}

- {props.apps && props.apps.length > 0 ? ( - props.apps.map((app, i) => ) - ) : props.apps ? ( - - ) : ( -
loading...
- )} -
- ) -} - -export interface ApplicationSection { - readonly apps?: ReadonlyArray - readonly header: string -} - -export interface AppLoaderProps { - readonly app?: Application - setApp(app?: Application): void - getApps(): Promise> -} - -/** - * Application sections/groups. Handles loading of the application - * sections, errors, opening applications, and killing applications. - */ -export const AppLoader: React.FunctionComponent = (props) => { - const [apps, setApps] = React.useState>() - const [error, setError] = React.useState() - - 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 ( - { - setError(undefined) - if (!props.app) { - refresh() - } - }} - /> - ) - } - - // If an app is currently loading, provide the option to cancel. - if (props.app && !props.app.loaded) { - return ( -
-
Opening
-
- -
- -
- ) - } - - // Apps are currently loading. - if (!apps) { - return ( -
-
loading...
-
- ) - } - - // Apps have loaded. - return ( - <> - {apps.map((section, i) => ( - - ))} - - ) -} diff --git a/src/browser/components/logo.tsx b/src/browser/components/logo.tsx deleted file mode 100644 index 8d5c9b36..00000000 --- a/src/browser/components/logo.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from "react" - -export const Logo = (props: React.SVGProps): JSX.Element => ( - - - - - - - - - - - - - - -) diff --git a/src/browser/components/modal.css b/src/browser/components/modal.css deleted file mode 100644 index bedc74f8..00000000 --- a/src/browser/components/modal.css +++ /dev/null @@ -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); -} diff --git a/src/browser/components/modal.tsx b/src/browser/components/modal.tsx deleted file mode 100644 index 6f5b63de..00000000 --- a/src/browser/components/modal.tsx +++ /dev/null @@ -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 = (props) => { - const [showModal, setShowModal] = React.useState(false) - const [showBar, setShowBar] = React.useState(false) // TEMP: Will be true. - const [section, setSection] = React.useState
(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 - } - switch (section) { - case Section.Recent: - return - case Section.Home: - return - case Section.Browse: - return - case Section.Open: - return - default: - return - } - } - - return props.error || showModal || !props.app || !props.app.loaded ? ( -
-
- {props.authed && (!props.app || props.app.loaded) ? ( - - ) : ( - undefined - )} - {props.error ? ( - { - props.setApp(undefined) - props.setError(undefined) - }} - /> - ) : ( -
{content()}
- )} -
-
- ) : ( - -
-
-
-
- Hover at the top {/*or press Ctrl+Shift+G*/} to display this menu. -
-
-
- -
- -
-
-
- ) -} diff --git a/src/browser/index.tsx b/src/browser/index.tsx deleted file mode 100644 index 0d31b11d..00000000 --- a/src/browser/index.tsx +++ /dev/null @@ -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(, document.getElementById("root")) diff --git a/src/browser/pages/app.css b/src/browser/pages/app.css new file mode 100644 index 00000000..ae091a2e --- /dev/null +++ b/src/browser/pages/app.css @@ -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; +} diff --git a/src/browser/index.html b/src/browser/pages/app.html similarity index 52% rename from src/browser/index.html rename to src/browser/pages/app.html index bbe20ef6..ce1b49b1 100644 --- a/src/browser/index.html +++ b/src/browser/pages/app.html @@ -3,17 +3,15 @@ - - code-server + + code-server — {{APP_NAME}} - - + -
{{COMPONENT}}
- + diff --git a/src/browser/pages/app.ts b/src/browser/pages/app.ts new file mode 100644 index 00000000..ea453b0e --- /dev/null +++ b/src/browser/pages/app.ts @@ -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) diff --git a/src/browser/pages/browse.tsx b/src/browser/pages/browse.tsx deleted file mode 100644 index 6b646558..00000000 --- a/src/browser/pages/browse.tsx +++ /dev/null @@ -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() - const [error, setError] = React.useState() - - React.useEffect(() => { - getFiles() - .then(setResponse) - .catch(setError) - }, [props]) - - return ( - <> - {error || (response && response.files.length === 0) ? ( - - ) : ( -
    - {((response && response.files) || []).map((f, i) => ( -
  • {f.name}
  • - ))} -
- )} - - ) -} diff --git a/src/browser/pages/error.css b/src/browser/pages/error.css new file mode 100644 index 00000000..aa8932e1 --- /dev/null +++ b/src/browser/pages/error.css @@ -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; +} diff --git a/src/browser/pages/error.html b/src/browser/pages/error.html new file mode 100644 index 00000000..2bb44c00 --- /dev/null +++ b/src/browser/pages/error.html @@ -0,0 +1,26 @@ + + + + + + + code-server {{ERROR_TITLE}} + + + + + + +
+
+

{{ERROR_HEADER}}

+
+ {{ERROR_BODY}} +
+ +
+ + diff --git a/src/browser/app.css b/src/browser/pages/global.css similarity index 50% rename from src/browser/app.css rename to src/browser/pages/global.css index 91cbba2c..e4c03134 100644 --- a/src/browser/app.css +++ b/src/browser/pages/global.css @@ -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; } diff --git a/src/browser/pages/home.css b/src/browser/pages/home.css index 6e56edd7..4ead54af 100644 --- a/src/browser/pages/home.css +++ b/src/browser/pages/home.css @@ -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; } diff --git a/src/browser/pages/home.html b/src/browser/pages/home.html new file mode 100644 index 00000000..abe33a1a --- /dev/null +++ b/src/browser/pages/home.html @@ -0,0 +1,34 @@ + + + + + + + code-server + + + + + + + +
+
+
+

Running Applications

+ {{APP_LIST:RUNNING}} +
+ +
+

Editors

+ {{APP_LIST:EDITORS}} +
+ +
+

Other

+ {{APP_LIST:OTHER}} +
+
+
+ + diff --git a/src/browser/pages/home.tsx b/src/browser/pages/home.tsx deleted file mode 100644 index 2af38ae7..00000000 --- a/src/browser/pages/home.tsx +++ /dev/null @@ -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 = (props) => { - React.useEffect(() => { - authenticate() - .then(() => setAuthed(true)) - .catch(() => undefined) - }, []) - - return ( -
-
Welcome to code-server.
- {props.app && !props.app.loaded ?
loading...
: undefined} -
- ) -} diff --git a/src/browser/pages/login.css b/src/browser/pages/login.css index 3a650e2f..08915951 100644 --- a/src/browser/pages/login.css +++ b/src/browser/pages/login.css @@ -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; } diff --git a/src/browser/pages/login.html b/src/browser/pages/login.html new file mode 100644 index 00000000..2cbd86dd --- /dev/null +++ b/src/browser/pages/login.html @@ -0,0 +1,48 @@ + + + + + + + code-server login + + + + + + +
+ +
+ + + diff --git a/src/browser/pages/login.tsx b/src/browser/pages/login.tsx deleted file mode 100644 index aaf9c490..00000000 --- a/src/browser/pages/login.tsx +++ /dev/null @@ -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 = (props) => { - const [password, setPassword] = React.useState("") - const [error, setError] = React.useState() - - async function handleSubmit(event: React.FormEvent): Promise { - 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 ( -
-
-
Welcome to code-server
-
Please log in below
-
-
- - ): void => setPassword(event.target.value)} - /> - -
- {error ? : undefined} - - ) -} diff --git a/src/browser/pages/missing.css b/src/browser/pages/missing.css deleted file mode 100644 index c376ed12..00000000 --- a/src/browser/pages/missing.css +++ /dev/null @@ -1,8 +0,0 @@ -.missing-message { - align-items: center; - color: #b6b6b6; - display: flex; - flex: 1; - flex-direction: column; - justify-content: center; -} diff --git a/src/browser/pages/missing.tsx b/src/browser/pages/missing.tsx deleted file mode 100644 index 65d5506b..00000000 --- a/src/browser/pages/missing.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import * as React from "react" - -export const Missing: React.FunctionComponent = () => { - return ( -
-
404
-
- ) -} diff --git a/src/browser/pages/open.tsx b/src/browser/pages/open.tsx deleted file mode 100644 index e2bd0027..00000000 --- a/src/browser/pages/open.tsx +++ /dev/null @@ -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 = (props) => { - return ( - > => { - const response = await getApplications() - return [ - { - header: "Applications", - apps: response && response.applications, - }, - ] - }} - {...props} - /> - ) -} diff --git a/src/browser/pages/recent.tsx b/src/browser/pages/recent.tsx deleted file mode 100644 index da57ddc5..00000000 --- a/src/browser/pages/recent.tsx +++ /dev/null @@ -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 = (props) => { - return ( - > => { - const response = await getRecent() - return [ - { - header: "Running Applications", - apps: response && response.running, - }, - { - header: "Recent Applications", - apps: response && response.recent, - }, - ] - }} - {...props} - /> - ) -} diff --git a/src/node/vscode/workbench-build.html b/src/browser/pages/vscode.html similarity index 87% rename from src/node/vscode/workbench-build.html rename to src/browser/pages/vscode.html index 86c0de0f..b4453957 100644 --- a/src/node/vscode/workbench-build.html +++ b/src/browser/pages/vscode.html @@ -4,6 +4,8 @@ + + @@ -12,8 +14,6 @@ - @@ -21,12 +21,16 @@ + - + @@ -78,12 +82,10 @@ }; + - + END_PROD_ONLY --> diff --git a/src/common/api.ts b/src/common/api.ts index d8829f55..b8d934c6 100644 --- a/src/common/api.ts +++ b/src/common/api.ts @@ -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 - readonly running: ReadonlyArray -} - -export interface FileEntry { - readonly type: "file" | "directory" - readonly name: string - readonly size: number -} - -export interface FilesResponse { - files: FileEntry[] + readonly running: ReadonlyArray } export interface HealthRequest { diff --git a/src/common/http.ts b/src/common/http.ts index 99c69d1a..ae53364f 100644 --- a/src/common/http.ts +++ b/src/common/http.ts @@ -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", } diff --git a/src/common/util.ts b/src/common/util.ts index eb5caed1..20cd0639 100644 --- a/src/common/util.ts +++ b/src/common/util.ts @@ -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 } /** diff --git a/src/node/api/server.ts b/src/node/api/server.ts deleted file mode 100644 index 339a8364..00000000 --- a/src/node/api/server.ts +++ /dev/null @@ -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 { - 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 { - if (!this.authenticated(request)) { - throw new Error("not authenticated") - } - await new Promise((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 { - 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> { - // 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> { - return { - content: { - files: [], - }, - } - } - - /** - * Return available applications. - */ - private async applications(): Promise> { - return { - content: { - applications: [Vscode], - }, - } - } -} diff --git a/src/node/vscode/README.md b/src/node/app/README.md similarity index 100% rename from src/node/vscode/README.md rename to src/node/app/README.md diff --git a/src/node/app/api.ts b/src/node/app/api.ts new file mode 100644 index 00000000..f2d7aa2a --- /dev/null +++ b/src/node/app/api.ts @@ -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() + + 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 { + 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 + case ApiEndpoint.session: + return this.session(request) + case ApiEndpoint.recent: + this.ensureMethod(request) + return { + content: await this.recent(), + } as HttpResponse + } + return undefined + } + + public async handleWebSocket( + route: Route, + request: http.IncomingMessage, + socket: net.Socket, + head: Buffer + ): Promise { + 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 { + const getMessageResponse = async (event: "health"): Promise => { + switch (event) { + case "health": + return { event, connections: await this.server.getConnections() } + default: + throw new Error("unexpected message") + } + } + + await new Promise((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 { + const sessionId = route.requestPath.replace(/^\//, "") + logger.debug("connecting session", field("sessionId", sessionId)) + const ws = await new Promise((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> { + return findWhitelistedApplications() + } + + /** + * Return installed applications. + */ + public async installedApplications(): Promise> { + 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 { + 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 + } + return { + content: { + created: true, + sessionId: await this.createSession(parsed), + }, + } as HttpResponse + } + } + + 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 { + 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 { + 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 { + return { + content: JSON.stringify({ error }), + } + } +} diff --git a/src/node/app/app.ts b/src/node/app/app.ts new file mode 100644 index 00000000..ff17b252 --- /dev/null +++ b/src/node/app/app.ts @@ -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 { + 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 { + 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 { + 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 { + return undefined + } + + private getAppRows(apps: ReadonlyArray): string { + return apps.length > 0 ? apps.map((app) => this.getAppRow(app)).join("\n") : `
None
` + } + + private getAppRow(app: Application): string { + return `
+ + ${ + app.icon + ? `` + : `
` + } +
${app.name}
+
+ ${ + app.sessionId + ? `
+ + +
` + : "" + } +
` + } +} diff --git a/src/node/app/bin.ts b/src/node/app/bin.ts new file mode 100644 index 00000000..2fa397e1 --- /dev/null +++ b/src/node/app/bin.ts @@ -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> => { + const apps: Application[] = [Vscode] + + return apps.sort((a, b): number => a.name.localeCompare(b.name)) +} + +export const findWhitelistedApplications = async (): Promise> => { + return [] +} diff --git a/src/node/app/login.ts b/src/node/app/login.ts new file mode 100644 index 00000000..207d7084 --- /dev/null +++ b/src/node/app/login.ts @@ -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 { + 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 { + 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 ? `
${error.message}
` : "") + return response + } + + /** + * Try logging in. On failure, show the login page with an error. + */ + private async tryLogin(route: Route, request: http.IncomingMessage): Promise { + // 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 { + 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 { + return undefined + } +} diff --git a/src/node/app/server.tsx b/src/node/app/server.tsx deleted file mode 100644 index a2ec93eb..00000000 --- a/src/node/app/server.tsx +++ /dev/null @@ -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 { - 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 { - 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()) - return response - } - - public async handleWebSocket(): Promise { - return undefined - } -} diff --git a/src/node/vscode/server.ts b/src/node/app/vscode.ts similarity index 92% rename from src/node/vscode/server.ts rename to src/node/app/vscode.ts index 6d26188b..ac8c973a 100644 --- a/src/node/vscode/server.ts +++ b/src/node/app/vscode.ts @@ -118,18 +118,28 @@ export class VscodeHttpProvider extends HttpProvider { } public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - 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)." : "" + }

${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(//g, "") + } + return { ...response, content: response.content @@ -208,15 +222,6 @@ export class VscodeHttpProvider extends HttpProvider { } } - private async getErrorRoot(error: Error): Promise { - 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)." : "" - }

${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 diff --git a/src/node/entry.ts b/src/node/entry.ts index 3ad02c29..d0b7c87d 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -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 => { + // 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 => { } 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()) diff --git a/src/node/http.ts b/src/node/http.ts index 5c2989e1..9d2783b6 100644 --- a/src/node/http.ts +++ b/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 { + 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(endpoint: string, provider: HttpProvider0): void - public registerHttpProvider( - endpoint: string, - provider: HttpProvider1, - a1: A1 - ): void + public registerHttpProvider(endpoint: string, provider: HttpProvider0): T + public registerHttpProvider(endpoint: string, provider: HttpProvider1, 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 => { + 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 => { try { this.heart.beat() diff --git a/src/node/vscode/error.html b/src/node/vscode/error.html deleted file mode 100644 index dc3ef0bb..00000000 --- a/src/node/vscode/error.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - code-server - - -
- {{ERROR}} -
- - - diff --git a/src/node/vscode/workbench.html b/src/node/vscode/workbench.html deleted file mode 100644 index bb61eaf6..00000000 --- a/src/node/vscode/workbench.html +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/yarn.lock b/yarn.lock index 51115d6a..802a4f1d 100644 --- a/yarn.lock +++ b/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"