Merge pull request #3277 from code-asher/logout

This commit is contained in:
Asher 2021-05-04 15:12:59 -05:00 committed by GitHub
commit 75e9e24e92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 127 additions and 125 deletions

View File

@ -19,3 +19,4 @@
# These are code-server code symlinks.
src/vs/base/node/proxy_agent.ts
src/vs/ipc.d.ts
src/vs/server/common/util.ts

View File

@ -1,8 +1,10 @@
import * as path from 'vs/base/common/path';
import { URI } from 'vs/base/common/uri';
import { Options } from 'vs/ipc';
import { localize } from 'vs/nls';
import { MenuId, MenuRegistry } from 'vs/platform/actions/common/actions';
import { CommandsRegistry } from 'vs/platform/commands/common/commands';
import { Extensions, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry';
import { ContextKeyExpr, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { registerSingleton } from 'vs/platform/instantiation/common/extensions';
import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection';
import { ILogService } from 'vs/platform/log/common/log';
@ -11,10 +13,18 @@ import { Registry } from 'vs/platform/registry/common/platform';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { TelemetryChannelClient } from 'vs/server/common/telemetry';
import { getOptions } from 'vs/server/common/util';
import 'vs/workbench/contrib/localizations/browser/localizations.contribution';
import 'vs/workbench/services/localizations/browser/localizationsService';
import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService';
/**
* All client-side customization to VS Code should live in this file when
* possible.
*/
const options = getOptions<Options>();
class TelemetryService extends TelemetryChannelClient {
public constructor(
@IRemoteAgentService remoteAgentService: IRemoteAgentService,
@ -23,26 +33,6 @@ class TelemetryService extends TelemetryChannelClient {
}
}
/**
* Remove extra slashes in a URL.
*/
export const normalize = (url: string, keepTrailing = false): string => {
return url.replace(/\/\/+/g, '/').replace(/\/+$/, keepTrailing ? '/' : '');
};
/**
* Get options embedded in the HTML.
*/
export const getOptions = <T extends Options>(): T => {
try {
return JSON.parse(document.getElementById('coder-options')!.getAttribute('data-settings')!);
} catch (error) {
return {} as T;
}
};
const options = getOptions();
const TELEMETRY_SECTION_ID = 'telemetry';
Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfiguration({
'id': TELEMETRY_SECTION_ID,
@ -173,38 +163,36 @@ export const initialize = async (services: ServiceCollection): Promise<void> =>
if (theme) {
localStorage.setItem('colorThemeData', theme);
}
};
export interface Query {
[key: string]: string | undefined;
}
// Use to show or hide logout commands and menu options.
const contextKeyService = (services.get(IContextKeyService) as IContextKeyService);
contextKeyService.createKey('code-server.authed', options.authed);
/**
* Split a string up to the delimiter. If the delimiter doesn't exist the first
* item will have all the text and the second item will be an empty string.
*/
export const split = (str: string, delimiter: string): [string, string] => {
const index = str.indexOf(delimiter);
return index !== -1 ? [str.substring(0, index).trim(), str.substring(index + 1)] : [str, ''];
};
// Add a logout command.
const logoutEndpoint = path.join(options.base, '/logout') + `?base=${options.base}`;
const LOGOUT_COMMAND_ID = 'code-server.logout';
CommandsRegistry.registerCommand(
LOGOUT_COMMAND_ID,
() => {
window.location.href = logoutEndpoint;
},
);
/**
* Return the URL modified with the specified query variables. It's pretty
* stupid so it probably doesn't cover any edge cases. Undefined values will
* unset existing values. Doesn't allow duplicates.
*/
export const withQuery = (url: string, replace: Query): string => {
const uri = URI.parse(url);
const query = { ...replace };
uri.query.split('&').forEach((kv) => {
const [key, value] = split(kv, '=');
if (!(key in query)) {
query[key] = value;
}
// Add logout to command palette.
MenuRegistry.appendMenuItem(MenuId.CommandPalette, {
command: {
id: LOGOUT_COMMAND_ID,
title: localize('logout', "Log out")
},
when: ContextKeyExpr.has('code-server.authed')
});
// Add logout to the (web-only) home menu.
MenuRegistry.appendMenuItem(MenuId.MenubarHomeMenu, {
command: {
id: LOGOUT_COMMAND_ID,
title: localize('logout', "Log out")
},
when: ContextKeyExpr.has('code-server.authed')
});
return uri.with({
query: Object.keys(query)
.filter((k) => typeof query[k] !== 'undefined')
.map((k) => `${k}=${query[k]}`).join('&'),
}).toString(true);
};

View File

@ -1,3 +0,0 @@
export enum Cookie {
Key = 'key',
}

View File

@ -0,0 +1 @@
../../../../../../src/common/util.ts

View File

@ -9,7 +9,7 @@ import { registerThemingParticipant, IThemeService } from 'vs/platform/theme/com
import { MenuBarVisibility, getTitleBarStyle, IWindowOpenable, getMenuBarVisibility } from 'vs/platform/windows/common/windows';
import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
import { IAction, Action, SubmenuAction, Separator } from 'vs/base/common/actions';
import { addDisposableListener, Dimension, EventType, getCookieValue } from 'vs/base/browser/dom';
import { addDisposableListener, Dimension, EventType } from 'vs/base/browser/dom';
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
import { isMacintosh, isWeb, isIOS, isNative } from 'vs/base/common/platform';
import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration';
@ -38,8 +38,6 @@ import { KeyCode } from 'vs/base/common/keyCodes';
import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry';
import { IsWebContext } from 'vs/platform/contextkey/common/contextkeys';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { ILogService } from 'vs/platform/log/common/log';
import { Cookie } from 'vs/server/common/cookie';
export type IOpenRecentAction = IAction & { uri: URI, remoteAuthority?: string };
@ -318,8 +316,7 @@ export class CustomMenubarControl extends MenubarControl {
@IThemeService private readonly themeService: IThemeService,
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
@IHostService protected readonly hostService: IHostService,
@ICommandService commandService: ICommandService,
@ILogService private readonly logService: ILogService
@ICommandService commandService: ICommandService
) {
super(menuService, workspacesService, contextKeyService, keybindingService, configurationService, labelService, updateService, storageService, notificationService, preferencesService, environmentService, accessibilityService, hostService, commandService);
@ -721,28 +718,6 @@ export class CustomMenubarControl extends MenubarControl {
webNavigationActions.pop();
}
webNavigationActions.push(new Action('logout', localize('logout', "Log out"), undefined, true,
async (event?: MouseEvent) => {
const COOKIE_KEY = Cookie.Key;
const loginCookie = getCookieValue(COOKIE_KEY);
this.logService.info('Logging out of code-server');
if(loginCookie) {
this.logService.info(`Removing cookie under ${COOKIE_KEY}`);
if (document && document.cookie) {
// We delete the cookie by setting the expiration to a date/time in the past
document.cookie = COOKIE_KEY +'=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
window.location.href = '/login';
} else {
this.logService.warn('Could not delete cookie because document and/or document.cookie is undefined');
}
} else {
this.logService.warn('Could not log out because we could not find cookie');
}
}));
return webNavigationActions;
}

View File

@ -1,3 +1,4 @@
import { logger } from "@coder/logger"
import { getOptions, normalize, logError } from "../common/util"
import "./pages/error.css"
@ -6,19 +7,21 @@ import "./pages/login.css"
export async function registerServiceWorker(): Promise<void> {
const options = getOptions()
logger.level = options.logLevel
const path = normalize(`${options.csStaticBase}/dist/serviceWorker.js`)
try {
await navigator.serviceWorker.register(path, {
scope: options.base + "/",
})
console.log("[Service Worker] registered")
logger.info(`[Service Worker] registered`)
} catch (error) {
logError(`[Service Worker] registration`, error)
logError(logger, `[Service Worker] registration`, error)
}
}
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
registerServiceWorker()
} else {
console.error(`[Service Worker] navigator is undefined`)
logger.error(`[Service Worker] navigator is undefined`)
}

View File

@ -1,5 +1,13 @@
import { logger, field } from "@coder/logger"
/*
* This file exists in two locations:
* - src/common/util.ts
* - lib/vscode/src/vs/server/common/util.ts
* The second is a symlink to the first.
*/
/**
* Base options included on every page.
*/
export interface Options {
base: string
csStaticBase: string
@ -69,6 +77,9 @@ export const getOptions = <T extends Options>(): T => {
options = {} as T
}
// You can also pass options in stringified form to the options query
// variable. Options provided here will override the ones in the options
// element.
const params = new URLSearchParams(location.search)
const queryOpts = params.get("options")
if (queryOpts) {
@ -78,13 +89,9 @@ export const getOptions = <T extends Options>(): T => {
}
}
logger.level = options.logLevel
options.base = resolveBase(options.base)
options.csStaticBase = resolveBase(options.csStaticBase)
logger.debug("got options", field("options", options))
return options
}
@ -113,7 +120,8 @@ export const getFirstString = (value: string | string[] | object | undefined): s
return typeof value === "string" ? value : undefined
}
export function logError(prefix: string, err: any): void {
// TODO: Might make sense to add Error handling to the logger itself.
export function logError(logger: { error: (msg: string) => void }, prefix: string, err: Error | string): void {
if (err instanceof Error) {
logger.error(`${prefix}: ${err.message} ${err.stack}`)
} else {

View File

@ -37,7 +37,7 @@ export const createApp = async (args: DefaultedArgs): Promise<[Express, Express,
reject(err)
} else {
// Promise resolved earlier so this is an unrelated error.
util.logError("http server error", err)
util.logError(logger, "http server error", err)
}
})

View File

@ -20,6 +20,7 @@ import * as apps from "./apps"
import * as domainProxy from "./domainProxy"
import * as health from "./health"
import * as login from "./login"
import * as logout from "./logout"
import * as pathProxy from "./pathProxy"
// static is a reserved keyword.
import * as _static from "./static"
@ -136,10 +137,10 @@ export const register = async (
if (args.auth === AuthType.Password) {
app.use("/login", login.router)
app.use("/logout", logout.router)
} else {
app.all("/login", (req, res) => {
redirect(req, res, "/", {})
})
app.all("/login", (req, res) => redirect(req, res, "/", {}))
app.all("/logout", (req, res) => redirect(req, res, "/", {}))
}
app.use("/static", _static.router)

17
src/node/routes/logout.ts Normal file
View File

@ -0,0 +1,17 @@
import { Router } from "express"
import { getCookieDomain, redirect } from "../http"
import { Cookie } from "./login"
export const router = Router()
router.get("/", async (req, res) => {
// Must use the *identical* properties used to set the cookie.
res.clearCookie(Cookie.Key, {
domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]),
path: req.body.base || "/",
sameSite: "lax",
})
const to = (typeof req.query.to === "string" && req.query.to) || "/"
return redirect(req, res, to, { to: undefined, base: undefined })
})

View File

@ -3,6 +3,7 @@ import { Request, Router } from "express"
import { promises as fs } from "fs"
import * as path from "path"
import qs from "qs"
import * as ipc from "../../../typings/ipc"
import { Emitter } from "../../common/emitter"
import { HttpCode, HttpError } from "../../common/http"
import { getFirstString } from "../../common/util"
@ -39,12 +40,13 @@ router.get("/", async (req, res) => {
options.productConfiguration.codeServerVersion = version
res.send(
replaceTemplates(
replaceTemplates<ipc.Options>(
req,
// Uncomment prod blocks if not in development. TODO: Would this be
// better as a build step? Or maintain two HTML files again?
commit !== "development" ? content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "") : content,
{
authed: req.args.auth !== "none",
disableTelemetry: !!req.args["disable-telemetry"],
disableUpdateCheck: !!req.args["disable-update-check"],
},

View File

@ -22,11 +22,11 @@ describe("register", () => {
})
beforeEach(() => {
jest.clearAllMocks()
jest.mock("@coder/logger", () => loggerModule)
})
afterEach(() => {
mockRegisterFn.mockClear()
jest.resetModules()
})
@ -39,6 +39,7 @@ describe("register", () => {
global.navigator = (undefined as unknown) as Navigator & typeof globalThis
global.location = (undefined as unknown) as Location & typeof globalThis
})
it("test should have access to browser globals from beforeAll", () => {
expect(typeof global.window).not.toBeFalsy()
expect(typeof global.document).not.toBeFalsy()
@ -74,24 +75,24 @@ describe("register", () => {
})
describe("when navigator and serviceWorker are NOT defined", () => {
let spy: jest.SpyInstance
beforeEach(() => {
spy = jest.spyOn(console, "error")
jest.clearAllMocks()
jest.mock("@coder/logger", () => loggerModule)
})
afterAll(() => {
jest.restoreAllMocks()
})
it("should log an error to the console", () => {
it("should log an error", () => {
// Load service worker like you would in the browser
require("../../src/browser/register")
expect(spy).toHaveBeenCalled()
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith("[Service Worker] navigator is undefined")
expect(loggerModule.logger.error).toHaveBeenCalled()
expect(loggerModule.logger.error).toHaveBeenCalledTimes(1)
expect(loggerModule.logger.error).toHaveBeenCalledWith("[Service Worker] navigator is undefined")
})
})
describe("registerServiceWorker", () => {
let serviceWorkerPath: string
let serviceWorkerScope: string

View File

@ -5,7 +5,7 @@ import { tmpdir } from "../../src/node/constants"
import { SettingsProvider, UpdateSettings } from "../../src/node/settings"
import { LatestResponse, UpdateProvider } from "../../src/node/update"
describe.skip("update", () => {
describe("update", () => {
let version = "1.0.0"
let spy: string[] = []
const server = http.createServer((request: http.IncomingMessage, response: http.ServerResponse) => {
@ -75,7 +75,7 @@ describe.skip("update", () => {
await expect(settings.read()).resolves.toEqual({ update })
expect(isNaN(update.checked)).toEqual(false)
expect(update.checked < Date.now() && update.checked >= now).toEqual(true)
expect(update.version).toBe("2.1.0")
expect(update.version).toStrictEqual("2.1.0")
expect(spy).toEqual(["/latest"])
})
@ -87,9 +87,9 @@ describe.skip("update", () => {
const update = await p.getUpdate()
await expect(settings.read()).resolves.toEqual({ update })
expect(isNaN(update.checked)).toBe(false)
expect(isNaN(update.checked)).toStrictEqual(false)
expect(update.checked < now).toBe(true)
expect(update.version).toBe("2.1.0")
expect(update.version).toStrictEqual("2.1.0")
expect(spy).toEqual([])
})
@ -101,10 +101,10 @@ describe.skip("update", () => {
const update = await p.getUpdate(true)
await expect(settings.read()).resolves.toEqual({ update })
expect(isNaN(update.checked)).toBe(false)
expect(update.checked < Date.now() && update.checked >= now).toBe(true)
expect(update.version).toBe("4.1.1")
expect(spy).toBe(["/latest"])
expect(isNaN(update.checked)).toStrictEqual(false)
expect(update.checked < Date.now() && update.checked >= now).toStrictEqual(true)
expect(update.version).toStrictEqual("4.1.1")
expect(spy).toStrictEqual(["/latest"])
})
it("should get latest after interval passes", async () => {
@ -121,8 +121,8 @@ describe.skip("update", () => {
await settings.write({ update: { checked, version } })
const update = await p.getUpdate()
expect(update.checked).not.toBe(checked)
expect(spy).toBe(["/latest"])
expect(update.checked).not.toStrictEqual(checked)
expect(spy).toStrictEqual(["/latest"])
})
it("should check if it's the current version", async () => {
@ -130,24 +130,31 @@ describe.skip("update", () => {
const p = provider()
let update = await p.getUpdate(true)
expect(p.isLatestVersion(update)).toBe(false)
expect(p.isLatestVersion(update)).toStrictEqual(false)
version = "0.0.0"
update = await p.getUpdate(true)
expect(p.isLatestVersion(update)).toBe(true)
expect(p.isLatestVersion(update)).toStrictEqual(true)
// Old version format; make sure it doesn't report as being later.
version = "999999.9999-invalid999.99.9"
update = await p.getUpdate(true)
expect(p.isLatestVersion(update)).toBe(true)
expect(p.isLatestVersion(update)).toStrictEqual(true)
})
it("should not reject if unable to fetch", async () => {
expect.assertions(2)
let provider = new UpdateProvider("invalid", settings)
await expect(() => provider.getUpdate(true)).resolves.toBe(undefined)
let now = Date.now()
let update = await provider.getUpdate(true)
expect(isNaN(update.checked)).toStrictEqual(false)
expect(update.checked < Date.now() && update.checked >= now).toEqual(true)
expect(update.version).toStrictEqual("unknown")
provider = new UpdateProvider("http://probably.invalid.dev.localhost/latest", settings)
await expect(() => provider.getUpdate(true)).resolves.toBe(undefined)
now = Date.now()
update = await provider.getUpdate(true)
expect(isNaN(update.checked)).toStrictEqual(false)
expect(update.checked < Date.now() && update.checked >= now).toEqual(true)
expect(update.version).toStrictEqual("unknown")
})
})

View File

@ -18,9 +18,6 @@ global.document = dom.window.document
export type LocationLike = Pick<Location, "pathname" | "origin">
// jest.mock is hoisted above the imports so we must use `require` here.
jest.mock("@coder/logger", () => require("../utils/helpers").loggerModule)
describe("util", () => {
describe("normalize", () => {
it("should remove multiple slashes", () => {
@ -236,14 +233,14 @@ describe("util", () => {
const message = "You don't have access to that folder."
const error = new Error(message)
logError("ui", error)
logError(loggerModule.logger, "ui", error)
expect(loggerModule.logger.error).toHaveBeenCalled()
expect(loggerModule.logger.error).toHaveBeenCalledWith(`ui: ${error.message} ${error.stack}`)
})
it("should log an error, even if not an instance of error", () => {
logError("api", "oh no")
logError(loggerModule.logger, "api", "oh no")
expect(loggerModule.logger.error).toHaveBeenCalled()
expect(loggerModule.logger.error).toHaveBeenCalledWith("api: oh no")

View File

@ -1,3 +1,4 @@
import { logger } from "@coder/logger"
import * as express from "express"
import * as http from "http"
import * as net from "net"
@ -45,7 +46,7 @@ export class HttpServer {
rej(err)
} else {
// Promise resolved earlier so this is some other error.
util.logError("http server error", err)
util.logError(logger, "http server error", err)
}
})
})

3
typings/ipc.d.ts vendored
View File

@ -6,9 +6,12 @@
* The second is a symlink to the first.
*/
export interface Options {
authed: boolean
base: string
csStaticBase: string
disableTelemetry: boolean
disableUpdateCheck: boolean
logLevel: number
}
export interface InitMessage {