commit
921262a096
@ -0,0 +1,152 @@
|
||||
import WS from 'ws';
|
||||
import { Socket as EIOSocket } from 'engine.io-client';
|
||||
|
||||
export interface GristClientSocketOptions {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class GristClientSocket {
|
||||
// Exactly one of _wsSocket and _eioSocket will be set at any one time.
|
||||
private _wsSocket: WS.WebSocket | WebSocket | undefined;
|
||||
private _eioSocket: EIOSocket | undefined;
|
||||
|
||||
// Set to true if a WebSocket connection (in _wsSocket) was succesfully
|
||||
// established. Errors from the underlying WebSocket are not forwarded to
|
||||
// the client until that point, in case we end up downgrading to Engine.IO.
|
||||
private _wsConnected: boolean = false;
|
||||
|
||||
private _messageHandler: null | ((data: string) => void);
|
||||
private _openHandler: null | (() => void);
|
||||
private _errorHandler: null | ((err: Error) => void);
|
||||
private _closeHandler: null | (() => void);
|
||||
|
||||
constructor(private _url: string, private _options?: GristClientSocketOptions) {
|
||||
this._createWSSocket();
|
||||
}
|
||||
|
||||
public set onmessage(cb: null | ((data: string) => void)) {
|
||||
this._messageHandler = cb;
|
||||
}
|
||||
|
||||
public set onopen(cb: null | (() => void)) {
|
||||
this._openHandler = cb;
|
||||
}
|
||||
|
||||
public set onerror(cb: null | ((err: Error) => void)) {
|
||||
this._errorHandler = cb;
|
||||
}
|
||||
|
||||
public set onclose(cb: null | (() => void)) {
|
||||
this._closeHandler = cb;
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (this._wsSocket) {
|
||||
this._wsSocket.close();
|
||||
} else {
|
||||
this._eioSocket!.close();
|
||||
}
|
||||
}
|
||||
|
||||
public send(data: string) {
|
||||
if (this._wsSocket) {
|
||||
this._wsSocket.send(data);
|
||||
} else {
|
||||
this._eioSocket!.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
// pause() and resume() are used for tests and assume a WS.WebSocket transport
|
||||
public pause() {
|
||||
(this._wsSocket as WS.WebSocket)?.pause();
|
||||
}
|
||||
|
||||
public resume() {
|
||||
(this._wsSocket as WS.WebSocket)?.resume();
|
||||
}
|
||||
|
||||
private _createWSSocket() {
|
||||
if (typeof WebSocket !== 'undefined') {
|
||||
this._wsSocket = new WebSocket(this._url);
|
||||
} else {
|
||||
this._wsSocket = new WS(this._url, undefined, this._options);
|
||||
}
|
||||
this._wsSocket.onmessage = this._onWSMessage.bind(this);
|
||||
this._wsSocket.onopen = this._onWSOpen.bind(this);
|
||||
this._wsSocket.onerror = this._onWSError.bind(this);
|
||||
this._wsSocket.onclose = this._onWSClose.bind(this);
|
||||
}
|
||||
|
||||
private _destroyWSSocket() {
|
||||
if (this._wsSocket) {
|
||||
this._wsSocket.onmessage = null;
|
||||
this._wsSocket.onopen = null;
|
||||
this._wsSocket.onerror = null;
|
||||
this._wsSocket.onclose = null;
|
||||
this._wsSocket = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _onWSMessage(event: WS.MessageEvent | MessageEvent<any>) {
|
||||
// event.data is guaranteed to be a string here because we only send text frames.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event#event_properties
|
||||
this._messageHandler?.(event.data);
|
||||
}
|
||||
|
||||
private _onWSOpen() {
|
||||
// The connection was established successfully. Any future events can now
|
||||
// be forwarded to the client.
|
||||
this._wsConnected = true;
|
||||
this._openHandler?.();
|
||||
}
|
||||
|
||||
private _onWSError(ev: Event) {
|
||||
if (!this._wsConnected) {
|
||||
// The WebSocket connection attempt failed. Switch to Engine.IO.
|
||||
this._destroyWSSocket();
|
||||
this._createEIOSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
// WebSocket error events are deliberately void of information,
|
||||
// see https://websockets.spec.whatwg.org/#eventdef-websocket-error,
|
||||
// so we ignore the incoming event.
|
||||
this._errorHandler?.(new Error("WebSocket error"));
|
||||
}
|
||||
|
||||
private _onWSClose() {
|
||||
this._closeHandler?.();
|
||||
}
|
||||
|
||||
private _createEIOSocket() {
|
||||
this._eioSocket = new EIOSocket(this._url, {
|
||||
path: new URL(this._url).pathname,
|
||||
addTrailingSlash: false,
|
||||
transports: ['polling'],
|
||||
upgrade: false,
|
||||
extraHeaders: this._options?.headers,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
this._eioSocket.on('message', this._onEIOMessage.bind(this));
|
||||
this._eioSocket.on('open', this._onEIOOpen.bind(this));
|
||||
this._eioSocket.on('error', this._onEIOError.bind(this));
|
||||
this._eioSocket.on('close', this._onEIOClose.bind(this));
|
||||
}
|
||||
|
||||
private _onEIOMessage(data: string) {
|
||||
this._messageHandler?.(data);
|
||||
}
|
||||
|
||||
private _onEIOOpen() {
|
||||
this._openHandler?.();
|
||||
}
|
||||
|
||||
private _onEIOError(err: string | Error) {
|
||||
this._errorHandler?.(typeof err === "string" ? new Error(err) : err);
|
||||
}
|
||||
|
||||
private _onEIOClose() {
|
||||
this._closeHandler?.();
|
||||
}
|
||||
}
|
@ -0,0 +1,270 @@
|
||||
import {getPageTitleSuffix} from 'app/common/gristUrls';
|
||||
import {getGristConfig} from 'app/common/urlUtils';
|
||||
import * as version from 'app/common/version';
|
||||
import {buildHomeBanners} from 'app/client/components/Banners';
|
||||
import {makeT} from 'app/client/lib/localization';
|
||||
import {AppModel} from 'app/client/models/AppModel';
|
||||
import {urlState} from 'app/client/models/gristUrlState';
|
||||
import {AppHeader} from 'app/client/ui/AppHeader';
|
||||
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
|
||||
import {pagePanels} from 'app/client/ui/PagePanels';
|
||||
import {SupportGristPage} from 'app/client/ui/SupportGristPage';
|
||||
import {createTopBarHome} from 'app/client/ui/TopBar';
|
||||
import {transition} from 'app/client/ui/transitions';
|
||||
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
|
||||
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
|
||||
import {icon} from 'app/client/ui2018/icons';
|
||||
import {cssLink} from 'app/client/ui2018/links';
|
||||
import {Disposable, dom, DomContents, IDisposableOwner, Observable, styled} from 'grainjs';
|
||||
|
||||
const t = makeT('AdminPanel');
|
||||
|
||||
// Translated "Admin Panel" name, made available to other modules.
|
||||
export function getAdminPanelName() {
|
||||
return t("Admin Panel");
|
||||
}
|
||||
|
||||
export class AdminPanel extends Disposable {
|
||||
private _supportGrist = SupportGristPage.create(this, this._appModel);
|
||||
|
||||
constructor(private _appModel: AppModel) {
|
||||
super();
|
||||
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
|
||||
}
|
||||
|
||||
public buildDom() {
|
||||
const panelOpen = Observable.create(this, false);
|
||||
return pagePanels({
|
||||
leftPanel: {
|
||||
panelWidth: Observable.create(this, 240),
|
||||
panelOpen,
|
||||
hideOpener: true,
|
||||
header: dom.create(AppHeader, this._appModel),
|
||||
content: leftPanelBasic(this._appModel, panelOpen),
|
||||
},
|
||||
headerMain: this._buildMainHeader(),
|
||||
contentTop: buildHomeBanners(this._appModel),
|
||||
contentMain: dom.create(this._buildMainContent.bind(this)),
|
||||
});
|
||||
}
|
||||
|
||||
private _buildMainHeader() {
|
||||
return dom.frag(
|
||||
cssBreadcrumbs({style: 'margin-left: 16px;'},
|
||||
cssLink(
|
||||
urlState().setLinkUrl({}),
|
||||
t('Home'),
|
||||
),
|
||||
separator(' / '),
|
||||
dom('span', getAdminPanelName()),
|
||||
),
|
||||
createTopBarHome(this._appModel),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildMainContent(owner: IDisposableOwner) {
|
||||
return cssPageContainer(
|
||||
dom.cls('clipboard'),
|
||||
{tabIndex: "-1"},
|
||||
cssSection(
|
||||
cssSectionTitle(t('Support Grist')),
|
||||
this._buildItem(owner, {
|
||||
id: 'telemetry',
|
||||
name: t('Telemetry'),
|
||||
description: t('Help us make Grist better'),
|
||||
value: maybeSwitchToggle(this._supportGrist.getTelemetryOptInObservable()),
|
||||
expandedContent: this._supportGrist.buildTelemetrySection(),
|
||||
}),
|
||||
this._buildItem(owner, {
|
||||
id: 'sponsor',
|
||||
name: t('Sponsor'),
|
||||
description: t('Support Grist Labs on GitHub'),
|
||||
value: this._supportGrist.buildSponsorshipSmallButton(),
|
||||
expandedContent: this._supportGrist.buildSponsorshipSection(),
|
||||
}),
|
||||
),
|
||||
cssSection(
|
||||
cssSectionTitle(t('Version')),
|
||||
this._buildItem(owner, {
|
||||
id: 'version',
|
||||
name: t('Current'),
|
||||
description: t('Current version of Grist'),
|
||||
value: cssValueLabel(`Version ${version.version}`),
|
||||
}),
|
||||
),
|
||||
testId('admin-panel'),
|
||||
);
|
||||
}
|
||||
|
||||
private _buildItem(owner: IDisposableOwner, options: {
|
||||
id: string,
|
||||
name: DomContents,
|
||||
description: DomContents,
|
||||
value: DomContents,
|
||||
expandedContent?: DomContents,
|
||||
}) {
|
||||
const itemContent = [
|
||||
cssItemName(options.name, testId(`admin-panel-item-name-${options.id}`)),
|
||||
cssItemDescription(options.description),
|
||||
cssItemValue(options.value,
|
||||
testId(`admin-panel-item-value-${options.id}`),
|
||||
dom.on('click', ev => ev.stopPropagation())),
|
||||
];
|
||||
if (options.expandedContent) {
|
||||
const isCollapsed = Observable.create(owner, true);
|
||||
return cssItem(
|
||||
cssItemShort(
|
||||
dom.domComputed(isCollapsed, (c) => cssCollapseIcon(c ? 'Expand' : 'Collapse')),
|
||||
itemContent,
|
||||
cssItemShort.cls('-expandable'),
|
||||
dom.on('click', () => isCollapsed.set(!isCollapsed.get())),
|
||||
),
|
||||
cssExpandedContentWrap(
|
||||
transition(isCollapsed, {
|
||||
prepare(elem, close) { elem.style.maxHeight = close ? elem.scrollHeight + 'px' : '0'; },
|
||||
run(elem, close) { elem.style.maxHeight = close ? '0' : elem.scrollHeight + 'px'; },
|
||||
finish(elem, close) { elem.style.maxHeight = close ? '0' : 'unset'; },
|
||||
}),
|
||||
cssExpandedContent(
|
||||
options.expandedContent,
|
||||
),
|
||||
),
|
||||
testId(`admin-panel-item-${options.id}`),
|
||||
);
|
||||
} else {
|
||||
return cssItem(
|
||||
cssItemShort(itemContent),
|
||||
testId(`admin-panel-item-${options.id}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maybeSwitchToggle(value: Observable<boolean|null>): DomContents {
|
||||
return dom('div.widget_switch',
|
||||
(elem) => elem.style.setProperty('--grist-actual-cell-color', theme.controlFg.toString()),
|
||||
dom.hide((use) => use(value) === null),
|
||||
dom.cls('switch_on', (use) => use(value) || false),
|
||||
dom.cls('switch_transition', true),
|
||||
dom.on('click', () => value.set(!value.get())),
|
||||
dom('div.switch_slider'),
|
||||
dom('div.switch_circle'),
|
||||
);
|
||||
}
|
||||
|
||||
const cssPageContainer = styled('div', `
|
||||
overflow: auto;
|
||||
padding: 40px;
|
||||
font-size: ${vars.introFontSize};
|
||||
color: ${theme.text};
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
padding: 0px;
|
||||
font-size: ${vars.mediumFontSize};
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSection = styled('div', `
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 16px auto;
|
||||
border: 1px solid ${theme.widgetBorder};
|
||||
border-radius: 4px;
|
||||
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
width: auto;
|
||||
padding: 12px;
|
||||
margin: 8px;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssSectionTitle = styled('div', `
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
margin-bottom: 16px;
|
||||
font-size: ${vars.headerControlFontSize};
|
||||
font-weight: ${vars.headerControlTextWeight};
|
||||
`);
|
||||
|
||||
const cssItem = styled('div', `
|
||||
margin-top: 8px;
|
||||
`);
|
||||
|
||||
const cssItemShort = styled('div', `
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
margin: 0 -8px;
|
||||
border-radius: 4px;
|
||||
&-expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
&-expandable:hover {
|
||||
background-color: ${theme.lightHover};
|
||||
}
|
||||
`);
|
||||
|
||||
const cssItemName = styled('div', `
|
||||
width: 112px;
|
||||
font-weight: bold;
|
||||
font-size: ${vars.largeFontSize};
|
||||
&:first-child {
|
||||
margin-left: 28px;
|
||||
}
|
||||
@media ${mediaSmall} {
|
||||
& {
|
||||
width: calc(100% - 28px);
|
||||
}
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const cssItemDescription = styled('div', `
|
||||
margin-right: auto;
|
||||
`);
|
||||
|
||||
const cssItemValue = styled('div', `
|
||||
flex: none;
|
||||
margin: -16px;
|
||||
padding: 16px;
|
||||
cursor: auto;
|
||||
`);
|
||||
|
||||
const cssCollapseIcon = styled(icon, `
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
margin-left: -4px;
|
||||
--icon-color: ${theme.lightText};
|
||||
`);
|
||||
|
||||
const cssExpandedContentWrap = styled('div', `
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
`);
|
||||
|
||||
const cssExpandedContent = styled('div', `
|
||||
margin-left: 24px;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid ${theme.widgetBorder};
|
||||
.${cssItem.className}:last-child & {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssValueLabel = styled('div', `
|
||||
padding: 4px 8px;
|
||||
color: ${theme.text};
|
||||
border: 1px solid ${theme.inputBorder};
|
||||
border-radius: ${vars.controlBorderRadius};
|
||||
`);
|
@ -0,0 +1,138 @@
|
||||
import * as WS from 'ws';
|
||||
import * as EIO from 'engine.io';
|
||||
|
||||
export abstract class GristServerSocket {
|
||||
public abstract set onerror(handler: (err: Error) => void);
|
||||
public abstract set onclose(handler: () => void);
|
||||
public abstract set onmessage(handler: (data: string) => void);
|
||||
public abstract removeAllListeners(): void;
|
||||
public abstract close(): void;
|
||||
public abstract terminate(): void;
|
||||
public abstract get isOpen(): boolean;
|
||||
public abstract send(data: string, cb?: (err?: Error) => void): void;
|
||||
}
|
||||
|
||||
export class GristServerSocketEIO extends GristServerSocket {
|
||||
private _eventHandlers: Array<{ event: string, handler: (...args: any[]) => void }> = [];
|
||||
private _messageCounter = 0;
|
||||
|
||||
// Engine.IO only invokes send() callbacks on success. We keep a map of
|
||||
// send callbacks for messages in flight so that we can invoke them for
|
||||
// any messages still unsent upon receiving a "close" event.
|
||||
private _messageCallbacks: Map<number, (err: Error) => void> = new Map();
|
||||
|
||||
constructor(private _socket: EIO.Socket) { super(); }
|
||||
|
||||
public set onerror(handler: (err: Error) => void) {
|
||||
// Note that as far as I can tell, Engine.IO sockets never emit "error"
|
||||
// but instead include error information in the "close" event.
|
||||
this._socket.on('error', handler);
|
||||
this._eventHandlers.push({ event: 'error', handler });
|
||||
}
|
||||
|
||||
public set onclose(handler: () => void) {
|
||||
const wrappedHandler = (reason: string, description: any) => {
|
||||
// In practice, when available, description has more error details,
|
||||
// possibly in the form of an Error object.
|
||||
const maybeErr = description ?? reason;
|
||||
const err = maybeErr instanceof Error ? maybeErr : new Error(maybeErr);
|
||||
for (const cb of this._messageCallbacks.values()) {
|
||||
cb(err);
|
||||
}
|
||||
this._messageCallbacks.clear();
|
||||
|
||||
handler();
|
||||
};
|
||||
this._socket.on('close', wrappedHandler);
|
||||
this._eventHandlers.push({ event: 'close', handler: wrappedHandler });
|
||||
}
|
||||
|
||||
public set onmessage(handler: (data: string) => void) {
|
||||
const wrappedHandler = (msg: Buffer) => {
|
||||
handler(msg.toString());
|
||||
};
|
||||
this._socket.on('message', wrappedHandler);
|
||||
this._eventHandlers.push({ event: 'message', handler: wrappedHandler });
|
||||
}
|
||||
|
||||
public removeAllListeners() {
|
||||
for (const { event, handler } of this._eventHandlers) {
|
||||
this._socket.off(event, handler);
|
||||
}
|
||||
this._eventHandlers = [];
|
||||
}
|
||||
|
||||
public close() {
|
||||
this._socket.close();
|
||||
}
|
||||
|
||||
// Terminates the connection without waiting for the client to close its own side.
|
||||
public terminate() {
|
||||
// Trigger a normal close. For the polling transport, this is sufficient and instantaneous.
|
||||
this._socket.close(/* discard */ true);
|
||||
}
|
||||
|
||||
public get isOpen() {
|
||||
return this._socket.readyState === 'open';
|
||||
}
|
||||
|
||||
public send(data: string, cb?: (err?: Error) => void) {
|
||||
const msgNum = this._messageCounter++;
|
||||
if (cb) {
|
||||
this._messageCallbacks.set(msgNum, cb);
|
||||
}
|
||||
this._socket.send(data, {}, () => {
|
||||
if (cb && this._messageCallbacks.delete(msgNum)) {
|
||||
// send was successful: pass no Error to callback
|
||||
cb();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class GristServerSocketWS extends GristServerSocket {
|
||||
private _eventHandlers: Array<{ event: string, handler: (...args: any[]) => void }> = [];
|
||||
|
||||
constructor(private _ws: WS.WebSocket) { super(); }
|
||||
|
||||
public set onerror(handler: (err: Error) => void) {
|
||||
this._ws.on('error', handler);
|
||||
this._eventHandlers.push({ event: 'error', handler });
|
||||
}
|
||||
|
||||
public set onclose(handler: () => void) {
|
||||
this._ws.on('close', handler);
|
||||
this._eventHandlers.push({ event: 'close', handler });
|
||||
}
|
||||
|
||||
public set onmessage(handler: (data: string) => void) {
|
||||
const wrappedHandler = (msg: Buffer) => handler(msg.toString());
|
||||
this._ws.on('message', wrappedHandler);
|
||||
this._eventHandlers.push({ event: 'message', handler: wrappedHandler });
|
||||
}
|
||||
|
||||
public removeAllListeners() {
|
||||
// Avoiding websocket.removeAllListeners() because WS.Server registers listeners
|
||||
// internally for websockets it keeps track of, and we should not accidentally remove those.
|
||||
for (const { event, handler } of this._eventHandlers) {
|
||||
this._ws.off(event, handler);
|
||||
}
|
||||
this._eventHandlers = [];
|
||||
}
|
||||
|
||||
public close() {
|
||||
this._ws.close();
|
||||
}
|
||||
|
||||
public terminate() {
|
||||
this._ws.terminate();
|
||||
}
|
||||
|
||||
public get isOpen() {
|
||||
return this._ws.readyState === WS.OPEN;
|
||||
}
|
||||
|
||||
public send(data: string, cb?: (err?: Error) => void) {
|
||||
this._ws.send(data, cb);
|
||||
}
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
import * as http from 'http';
|
||||
import * as WS from 'ws';
|
||||
import * as EIO from 'engine.io';
|
||||
import {GristServerSocket, GristServerSocketEIO, GristServerSocketWS} from './GristServerSocket';
|
||||
import * as net from 'net';
|
||||
|
||||
const MAX_PAYLOAD = 100e6;
|
||||
|
||||
export interface GristSocketServerOptions {
|
||||
verifyClient?: (request: http.IncomingMessage) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export class GristSocketServer {
|
||||
private _wsServer: WS.Server;
|
||||
private _eioServer: EIO.Server;
|
||||
private _connectionHandler: (socket: GristServerSocket, req: http.IncomingMessage) => void;
|
||||
|
||||
constructor(server: http.Server, private _options?: GristSocketServerOptions) {
|
||||
this._wsServer = new WS.Server({ noServer: true, maxPayload: MAX_PAYLOAD });
|
||||
|
||||
this._eioServer = new EIO.Server({
|
||||
// We only use Engine.IO for its polling transport,
|
||||
// so we disable the built-in Engine.IO upgrade mechanism.
|
||||
allowUpgrades: false,
|
||||
transports: ['polling'],
|
||||
maxHttpBufferSize: MAX_PAYLOAD,
|
||||
cors: {
|
||||
// This will cause Engine.IO to reflect any client-provided Origin into
|
||||
// the Access-Control-Allow-Origin header, essentially disabling the
|
||||
// protection offered by the Same-Origin Policy. This sounds insecure
|
||||
// but is actually the security model of native WebSockets (they are
|
||||
// not covered by SOP; any webpage can open a WebSocket connecting to
|
||||
// any other domain, including the target domain's cookies; it is up to
|
||||
// the receiving server to check the request's Origin header). Since
|
||||
// the connection attempt is validated in `verifyClient` later,
|
||||
// it is safe to let any client attempt a connection here.
|
||||
origin: true,
|
||||
// We need to allow the client to send its cookies. See above for the
|
||||
// reasoning on why it is safe to do so.
|
||||
credentials: true,
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
});
|
||||
|
||||
this._eioServer.on('connection', this._onEIOConnection.bind(this));
|
||||
|
||||
this._attach(server);
|
||||
}
|
||||
|
||||
public set onconnection(handler: (socket: GristServerSocket, req: http.IncomingMessage) => void) {
|
||||
this._connectionHandler = handler;
|
||||
}
|
||||
|
||||
public close(cb: (...args: any[]) => void) {
|
||||
this._eioServer.close();
|
||||
|
||||
// Terminate all clients. WS.Server used to do it automatically in close() but no
|
||||
// longer does (see https://github.com/websockets/ws/pull/1904#discussion_r668844565).
|
||||
for (const ws of this._wsServer.clients) {
|
||||
ws.terminate();
|
||||
}
|
||||
this._wsServer.close(cb);
|
||||
}
|
||||
|
||||
private _attach(server: http.Server) {
|
||||
// Forward all WebSocket upgrade requests to WS
|
||||
server.on('upgrade', async (request, socket, head) => {
|
||||
if (this._options?.verifyClient && !await this._options.verifyClient(request)) {
|
||||
// Because we are handling an "upgrade" event, we don't have access to
|
||||
// a "response" object, just the raw socket. We can still construct
|
||||
// a well-formed HTTP error response.
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
this._wsServer.handleUpgrade(request, socket as net.Socket, head, (client) => {
|
||||
this._connectionHandler?.(new GristServerSocketWS(client), request);
|
||||
});
|
||||
});
|
||||
|
||||
// At this point an Express app is installed as the handler for the server's
|
||||
// "request" event. We need to install our own listener instead, to intercept
|
||||
// requests that are meant for the Engine.IO polling implementation.
|
||||
const listeners = [...server.listeners("request")];
|
||||
server.removeAllListeners("request");
|
||||
server.on("request", async (req, res) => {
|
||||
// Intercept requests that have transport=polling in their querystring
|
||||
if (/[&?]transport=polling(&|$)/.test(req.url ?? '')) {
|
||||
if (this._options?.verifyClient && !await this._options.verifyClient(req)) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
|
||||
this._eioServer.handleRequest(req, res);
|
||||
} else {
|
||||
// Otherwise fallback to the pre-existing listener(s)
|
||||
for (const listener of listeners) {
|
||||
listener.call(server, req, res);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.on("close", this.close.bind(this));
|
||||
}
|
||||
|
||||
private _onEIOConnection(socket: EIO.Socket) {
|
||||
const req = socket.request;
|
||||
(socket as any).request = null; // Free initial request as recommended in the Engine.IO documentation
|
||||
this._connectionHandler?.(new GristServerSocketEIO(socket), req);
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import {appSettings} from 'app/server/lib/AppSettings';
|
||||
import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||
import {User} from 'app/gen-server/entity/User';
|
||||
import express from 'express';
|
||||
|
||||
/**
|
||||
* Class implementing the logic to determine whether a user is authorized to manage the Grist
|
||||
* installation.
|
||||
*/
|
||||
export abstract class InstallAdmin {
|
||||
|
||||
// Returns true if user is authorized to manage the Grist installation.
|
||||
public abstract isAdminUser(user: User): Promise<boolean>;
|
||||
|
||||
// Returns true if req is authenticated (contains a user) and the user is authorized to manage
|
||||
// the Grist installation. This should not fail, only return true or false.
|
||||
public async isAdminReq(req: express.Request): Promise<boolean> {
|
||||
const user = (req as RequestWithLogin).user;
|
||||
return user ? this.isAdminUser(user) : false;
|
||||
}
|
||||
|
||||
// Returns middleware that fails unless the request includes an authenticated user and this user
|
||||
// is authorized to manage the Grist installation.
|
||||
public getMiddlewareRequireAdmin(): express.RequestHandler {
|
||||
return this._requireAdmin.bind(this);
|
||||
}
|
||||
|
||||
private async _requireAdmin(req: express.Request, resp: express.Response, next: express.NextFunction) {
|
||||
try {
|
||||
// getUser() will fail with 401 if user is not present.
|
||||
if (!await this.isAdminUser(getUser(req))) {
|
||||
throw new ApiError('Access denied', 403);
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Considers the user whose email matches GRIST_DEFAULT_EMAIL env var, if given, to be the
|
||||
// installation admin. If not given, then there is no admin.
|
||||
export class SimpleInstallAdmin extends InstallAdmin {
|
||||
private _installAdminEmail = appSettings.section('access').flag('installAdminEmail').readString({
|
||||
envVar: 'GRIST_DEFAULT_EMAIL',
|
||||
});
|
||||
|
||||
public override async isAdminUser(user: User): Promise<boolean> {
|
||||
return this._installAdminEmail ? (user.loginEmail === this._installAdminEmail) : false;
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,195 @@
|
||||
import {TelemetryLevel} from 'app/common/Telemetry';
|
||||
import {assert, driver, Key, WebElement} from 'mocha-webdriver';
|
||||
import * as gu from 'test/nbrowser/gristUtils';
|
||||
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
|
||||
import * as testUtils from 'test/server/testUtils';
|
||||
|
||||
describe('AdminPanel', function() {
|
||||
this.timeout(30000);
|
||||
setupTestSuite();
|
||||
|
||||
let oldEnv: testUtils.EnvironmentSnapshot;
|
||||
let session: gu.Session;
|
||||
|
||||
afterEach(() => gu.checkForErrors());
|
||||
|
||||
before(async function() {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
process.env.GRIST_TEST_SERVER_DEPLOYMENT_TYPE = 'core';
|
||||
process.env.GRIST_DEFAULT_EMAIL = gu.session().email;
|
||||
await server.restart(true);
|
||||
});
|
||||
|
||||
after(async function() {
|
||||
oldEnv.restore();
|
||||
await server.restart(true);
|
||||
});
|
||||
|
||||
it('should not be shown to non-managers', async function() {
|
||||
session = await gu.session().user('user2').personalSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
|
||||
await gu.openAccountMenu();
|
||||
assert.equal(await driver.find('.test-usermenu-admin-panel').isPresent(), false);
|
||||
await driver.sendKeys(Key.ESCAPE);
|
||||
assert.equal(await driver.find('.test-dm-admin-panel').isPresent(), false);
|
||||
|
||||
// Try loading the URL directly.
|
||||
await driver.get(`${server.getHost()}/admin`);
|
||||
assert.match(await driver.findWait('.test-error-header', 2000).getText(), /Access denied/);
|
||||
assert.equal(await driver.find('.test-admin-panel').isPresent(), false);
|
||||
});
|
||||
|
||||
it('should be shown to managers', async function() {
|
||||
session = await gu.session().personalSite.login();
|
||||
await session.loadDocMenu('/');
|
||||
assert.equal(await driver.find('.test-dm-admin-panel').isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-dm-admin-panel').getAttribute('href'), /\/admin$/);
|
||||
await gu.openAccountMenu();
|
||||
assert.equal(await driver.find('.test-usermenu-admin-panel').isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-usermenu-admin-panel').getAttribute('href'), /\/admin$/);
|
||||
await driver.find('.test-usermenu-admin-panel').click();
|
||||
assert.equal(await waitForAdminPanel().isDisplayed(), true);
|
||||
});
|
||||
|
||||
it('should include support-grist section', async function() {
|
||||
assert.match(await driver.find('.test-admin-panel-item-sponsor').getText(), /Support Grist Labs on GitHub/);
|
||||
await withExpandedItem('sponsor', async () => {
|
||||
const button = await driver.find('.test-support-grist-page-sponsorship-section');
|
||||
assert.equal(await button.isDisplayed(), true);
|
||||
assert.match(await button.getText(), /You can support Grist open-source/);
|
||||
});
|
||||
});
|
||||
|
||||
it('supports opting in to telemetry from the page', async function() {
|
||||
await assertTelemetryLevel('off');
|
||||
|
||||
let toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
|
||||
await withExpandedItem('telemetry', async () => {
|
||||
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
|
||||
await driver.findContentWait(
|
||||
'.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000).click();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
|
||||
assert.equal(
|
||||
await driver.find('.test-support-grist-page-telemetry-section-message').getText(),
|
||||
'You have opted in to telemetry. Thank you! 🙏'
|
||||
);
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
});
|
||||
|
||||
// Check it's still on after collapsing.
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
|
||||
// Reload the page and check that the Grist config indicates telemetry is set to "limited".
|
||||
await driver.navigate().refresh();
|
||||
await waitForAdminPanel();
|
||||
toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
await toggleItem('telemetry');
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000);
|
||||
assert.equal(
|
||||
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
|
||||
'You have opted in to telemetry. Thank you! 🙏'
|
||||
);
|
||||
await assertTelemetryLevel('limited');
|
||||
});
|
||||
|
||||
it('supports opting out of telemetry from the page', async function() {
|
||||
await driver.findContent('.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/).click();
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
|
||||
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
|
||||
let toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
|
||||
// Reload the page and check that the Grist config indicates telemetry is set to "off".
|
||||
await driver.navigate().refresh();
|
||||
await waitForAdminPanel();
|
||||
await toggleItem('telemetry');
|
||||
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
|
||||
assert.isFalse(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent());
|
||||
await assertTelemetryLevel('off');
|
||||
toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
});
|
||||
|
||||
it('supports toggling telemetry from the toggle in the top line', async function() {
|
||||
const toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
await toggle.click();
|
||||
await gu.waitForServer();
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
assert.match(await driver.find('.test-support-grist-page-telemetry-section-message').getText(),
|
||||
/You have opted in/);
|
||||
await toggle.click();
|
||||
await gu.waitForServer();
|
||||
assert.equal(await isSwitchOn(toggle), false);
|
||||
await withExpandedItem('telemetry', async () => {
|
||||
assert.equal(await driver.find('.test-support-grist-page-telemetry-section-message').isPresent(), false);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows telemetry opt-in status even when set via environment variable', async function() {
|
||||
// Set the telemetry level to "limited" via environment variable and restart the server.
|
||||
process.env.GRIST_TELEMETRY_LEVEL = 'limited';
|
||||
await server.restart();
|
||||
|
||||
// Check that the Support Grist page reports telemetry is enabled.
|
||||
await driver.get(`${server.getHost()}/admin`);
|
||||
await waitForAdminPanel();
|
||||
const toggle = driver.find('.test-admin-panel-item-value-telemetry .widget_switch');
|
||||
assert.equal(await isSwitchOn(toggle), true);
|
||||
await toggleItem('telemetry');
|
||||
assert.equal(
|
||||
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
|
||||
'You have opted in to telemetry. Thank you! 🙏'
|
||||
);
|
||||
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
|
||||
/Opt out of Telemetry/).isPresent());
|
||||
|
||||
// Now set the telemetry level to "off" and restart the server.
|
||||
process.env.GRIST_TELEMETRY_LEVEL = 'off';
|
||||
await server.restart();
|
||||
|
||||
// Check that the Support Grist page reports telemetry is disabled.
|
||||
await driver.get(`${server.getHost()}/admin`);
|
||||
await waitForAdminPanel();
|
||||
await toggleItem('telemetry');
|
||||
assert.equal(
|
||||
await driver.findWait('.test-support-grist-page-telemetry-section-message', 2000).getText(),
|
||||
'You have opted out of telemetry.'
|
||||
);
|
||||
assert.isFalse(await driver.findContent('.test-support-grist-page-telemetry-section button',
|
||||
/Opt in to Telemetry/).isPresent());
|
||||
});
|
||||
|
||||
it('should show version', async function() {
|
||||
await driver.get(`${server.getHost()}/admin`);
|
||||
await waitForAdminPanel();
|
||||
assert.equal(await driver.find('.test-admin-panel-item-version').isDisplayed(), true);
|
||||
assert.match(await driver.find('.test-admin-panel-item-value-version').getText(), /^Version \d+\./);
|
||||
});
|
||||
});
|
||||
|
||||
async function assertTelemetryLevel(level: TelemetryLevel) {
|
||||
const telemetryLevel = await driver.executeScript('return window.gristConfig.telemetry?.telemetryLevel');
|
||||
assert.equal(telemetryLevel, level);
|
||||
}
|
||||
|
||||
async function toggleItem(itemId: string) {
|
||||
const header = await driver.find(`.test-admin-panel-item-name-${itemId}`);
|
||||
await header.click();
|
||||
await driver.sleep(500); // Time to expand or collapse.
|
||||
return header;
|
||||
}
|
||||
|
||||
async function withExpandedItem(itemId: string, callback: () => Promise<void>) {
|
||||
const header = await toggleItem(itemId);
|
||||
await callback();
|
||||
await header.click();
|
||||
await driver.sleep(500); // Time to collapse.
|
||||
}
|
||||
|
||||
const isSwitchOn = (switchElem: WebElement) => switchElem.matches('[class*=switch_on]');
|
||||
const waitForAdminPanel = () => driver.findWait('.test-admin-panel', 2000);
|
@ -0,0 +1,170 @@
|
||||
import { assert } from 'chai';
|
||||
import * as http from 'http';
|
||||
import { GristClientSocket } from 'app/client/components/GristClientSocket';
|
||||
import { GristSocketServer } from 'app/server/lib/GristSocketServer';
|
||||
import { fromCallback, listenPromise } from 'app/server/lib/serverUtils';
|
||||
import { AddressInfo } from 'net';
|
||||
import httpProxy from 'http-proxy';
|
||||
|
||||
describe(`GristSockets`, function () {
|
||||
|
||||
for (const webSocketsSupported of [true, false]) {
|
||||
describe(`when the networks ${webSocketsSupported ? "supports" : "does not support"} WebSockets`, function () {
|
||||
|
||||
let server: http.Server | null;
|
||||
let serverPort: number;
|
||||
let socketServer: GristSocketServer | null;
|
||||
let proxy: httpProxy | null;
|
||||
let proxyServer: http.Server | null;
|
||||
let proxyPort: number;
|
||||
let wsAddress: string;
|
||||
|
||||
beforeEach(async function () {
|
||||
await startSocketServer();
|
||||
await startProxyServer();
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
await stopProxyServer();
|
||||
await stopSocketServer();
|
||||
});
|
||||
|
||||
async function startSocketServer() {
|
||||
server = http.createServer((req, res) => res.writeHead(404).end());
|
||||
socketServer = new GristSocketServer(server);
|
||||
await listenPromise(server.listen(0, 'localhost'));
|
||||
serverPort = (server.address() as AddressInfo).port;
|
||||
}
|
||||
|
||||
async function stopSocketServer() {
|
||||
await fromCallback(cb => socketServer?.close(cb));
|
||||
await fromCallback(cb => { server?.close(); server?.closeAllConnections(); server?.on("close", cb); });
|
||||
socketServer = server = null;
|
||||
}
|
||||
|
||||
// Start an HTTP proxy that supports WebSockets or not
|
||||
async function startProxyServer() {
|
||||
proxy = httpProxy.createProxy({
|
||||
target: `http://localhost:${serverPort}`,
|
||||
ws: webSocketsSupported,
|
||||
timeout: 1000,
|
||||
});
|
||||
proxy.on('error', () => { });
|
||||
proxyServer = http.createServer();
|
||||
|
||||
if (webSocketsSupported) {
|
||||
// prevent non-WebSocket requests
|
||||
proxyServer.on('request', (req, res) => res.writeHead(404).end());
|
||||
// proxy WebSocket requests
|
||||
proxyServer.on('upgrade', (req, socket, head) => proxy!.ws(req, socket, head));
|
||||
} else {
|
||||
// proxy non-WebSocket requests
|
||||
proxyServer.on('request', (req, res) => proxy!.web(req, res));
|
||||
// don't leave WebSocket connection attempts hanging
|
||||
proxyServer.on('upgrade', (req, socket, head) => socket.destroy());
|
||||
}
|
||||
|
||||
await listenPromise(proxyServer.listen(0, 'localhost'));
|
||||
proxyPort = (proxyServer.address() as AddressInfo).port;
|
||||
wsAddress = `ws://localhost:${proxyPort}`;
|
||||
}
|
||||
|
||||
async function stopProxyServer() {
|
||||
if (proxy) {
|
||||
proxy.close();
|
||||
proxy = null;
|
||||
}
|
||||
if (proxyServer) {
|
||||
const server = proxyServer;
|
||||
await fromCallback(cb => { server.close(cb); server.closeAllConnections(); });
|
||||
}
|
||||
proxyServer = null;
|
||||
}
|
||||
|
||||
function getMessages(ws: GristClientSocket, count: number): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const messages: string[] = [];
|
||||
ws.onerror = (err) => {
|
||||
ws.onerror = ws.onmessage = null;
|
||||
reject(err);
|
||||
};
|
||||
ws.onmessage = (data: string) => {
|
||||
messages.push(data);
|
||||
if (messages.length >= count) {
|
||||
ws.onerror = ws.onmessage = null;
|
||||
resolve(messages);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a promise for the connected websocket.
|
||||
*/
|
||||
function connectClient(url: string): Promise<GristClientSocket> {
|
||||
const socket = new GristClientSocket(url);
|
||||
return new Promise<GristClientSocket>((resolve, reject) => {
|
||||
socket.onopen = () => {
|
||||
socket.onerror = null;
|
||||
resolve(socket);
|
||||
};
|
||||
socket.onerror = (err) => {
|
||||
socket.onopen = null;
|
||||
reject(err);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
it("should expose initial request", async function () {
|
||||
const connectionPromise = new Promise<http.IncomingMessage>((resolve) => {
|
||||
socketServer!.onconnection = (socket, req) => {
|
||||
resolve(req);
|
||||
};
|
||||
});
|
||||
const clientWs = new GristClientSocket(wsAddress + "/path?query=value", {
|
||||
headers: { "cookie": "session=1234" }
|
||||
});
|
||||
const req = await connectionPromise;
|
||||
clientWs.close();
|
||||
|
||||
// Engine.IO may append extra query parameters, so we check only the start of the URL
|
||||
assert.match(req.url!, /^\/path\?query=value/);
|
||||
|
||||
assert.equal(req.headers.cookie, "session=1234");
|
||||
});
|
||||
|
||||
it("should receive and send messages", async function () {
|
||||
socketServer!.onconnection = (socket, req) => {
|
||||
socket.onmessage = (data) => {
|
||||
socket.send("hello, " + data);
|
||||
};
|
||||
};
|
||||
const clientWs = await connectClient(wsAddress);
|
||||
clientWs.send("world");
|
||||
assert.deepEqual(await getMessages(clientWs, 1), ["hello, world"]);
|
||||
clientWs.close();
|
||||
});
|
||||
|
||||
it("should invoke send callbacks", async function () {
|
||||
const connectionPromise = new Promise<void>((resolve) => {
|
||||
socketServer!.onconnection = (socket, req) => {
|
||||
socket.send("hello", () => resolve());
|
||||
};
|
||||
});
|
||||
const clientWs = await connectClient(wsAddress);
|
||||
await connectionPromise;
|
||||
clientWs.close();
|
||||
});
|
||||
|
||||
it("should emit close event for client", async function () {
|
||||
const clientWs = await connectClient(wsAddress);
|
||||
const closePromise = new Promise<void>(resolve => {
|
||||
clientWs.onclose = resolve;
|
||||
});
|
||||
clientWs.close();
|
||||
await closePromise;
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
});
|
Loading…
Reference in new issue