Merge branch 'main' into create-team-site

pull/903/head
CamilleLegeron 2 months ago
commit 921262a096

@ -266,7 +266,7 @@ GRIST_DEFAULT_LOCALE | Locale to use as fallback when Grist cannot honour the b
GRIST_DOMAIN | in hosted Grist, Grist is served from subdomains of this domain. Defaults to "getgrist.com".
GRIST_EXPERIMENTAL_PLUGINS | enables experimental plugins
GRIST_ENABLE_REQUEST_FUNCTION | enables the REQUEST function. This function performs HTTP requests in a similar way to `requests.request`. This function presents a significant security risk, since it can let users call internal endpoints when Grist is available publicly. This function can also cause performance issues. Unset by default.
GRIST_HIDE_UI_ELEMENTS | comma-separated list of UI features to disable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials`. If a part also exists in GRIST_UI_FEATURES, it will still be disabled.
GRIST_HIDE_UI_ELEMENTS | comma-separated list of UI features to disable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_UI_FEATURES, it will still be disabled.
GRIST_HOST | hostname to use when listening on a port.
GRIST_HTTPS_PROXY | if set, use this proxy for webhook payload delivery.
GRIST_ID_PREFIX | for subdomains of form o-*, expect or produce o-${GRIST_ID_PREFIX}*.
@ -301,7 +301,7 @@ GRIST_TELEMETRY_LEVEL | the telemetry level. Can be set to: `off` (default), `li
GRIST_THROTTLE_CPU | if set, CPU throttling is enabled
GRIST_TRUST_PLUGINS | if set, plugins are expect to be served from the same host as the rest of the Grist app, rather than from a distinct host. Ordinarily, plugins are served from a distinct host so that the cookies used by the Grist app are not automatically available to them. Enable this only if you understand the security implications.
GRIST_USER_ROOT | an extra path to look for plugins in - Grist will scan for plugins in `$GRIST_USER_ROOT/plugins`.
GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled.
GRIST_UI_FEATURES | comma-separated list of UI features to enable. Allowed names of parts: `helpCenter,billing,templates,createSite,multiSite,multiAccounts,sendToDrive,tutorials,supportGrist`. If a part also exists in GRIST_HIDE_UI_ELEMENTS, it won't be enabled.
GRIST_UNTRUSTED_PORT | if set, plugins will be served from the given port. This is an alternative to setting APP_UNTRUSTED_URL.
GRIST_WIDGET_LIST_URL | a url pointing to a widget manifest, by default `https://github.com/gristlabs/grist-widget/releases/download/latest/manifest.json` is used
COOKIE_MAX_AGE | session cookie max age, defaults to 90 days; can be set to "none" to make it a session cookie

@ -496,7 +496,7 @@ export class FormView extends Disposable {
async (dontShowAgain) => {
await this._publishForm();
if (dontShowAgain) {
this.gristDoc.appModel.dismissedPopup('publishForm').set(true);
this.gristDoc.appModel.dismissPopup('publishForm', true);
}
},
{
@ -588,7 +588,7 @@ export class FormView extends Disposable {
async (dontShowAgain) => {
await this._unpublishForm();
if (dontShowAgain) {
this.gristDoc.appModel.dismissedPopup('unpublishForm').set(true);
this.gristDoc.appModel.dismissPopup('unpublishForm', true);
}
},
{

@ -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?.();
}
}

@ -9,6 +9,7 @@ import {addOrgToPath, docUrl, getGristConfig} from 'app/common/urlUtils';
import {UserAPI} from 'app/common/UserAPI';
import {Events as BackboneEvents} from 'backbone';
import {Disposable} from 'grainjs';
import {GristClientSocket} from './GristClientSocket';
const G = getBrowserGlobals('window');
const reconnectInterval = [1000, 1000, 2000, 5000, 10000];
@ -37,7 +38,7 @@ async function getDocWorkerUrl(assignmentId: string|null): Promise<string|null>
export interface GristWSSettings {
// A factory function for creating the WebSocket so that we can use from node
// or browser.
makeWebSocket(url: string): WebSocket;
makeWebSocket(url: string): GristClientSocket;
// A function for getting the timezone, so the code can be used outside webpack -
// currently a timezone library is lazy loaded in a way that doesn't quite work
@ -74,7 +75,7 @@ export interface GristWSSettings {
export class GristWSSettingsBrowser implements GristWSSettings {
private _sessionStorage = getSessionStorage();
public makeWebSocket(url: string) { return new WebSocket(url); }
public makeWebSocket(url: string) { return new GristClientSocket(url); }
public getTimezone() { return guessTimezone(); }
public getPageUrl() { return G.window.location.href; }
public async getDocWorkerUrl(assignmentId: string|null) {
@ -125,7 +126,7 @@ export class GristWSConnection extends Disposable {
private _reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private _reconnectAttempts: number = 0;
private _wantReconnect: boolean = true;
private _ws: WebSocket|null = null;
private _ws: GristClientSocket|null = null;
// The server sends incremental seqId numbers with each message on the connection, starting with
// 0. We keep track of them to allow for seamless reconnects.
@ -211,17 +212,16 @@ export class GristWSConnection extends Disposable {
}
/**
* @event serverMessage Triggered when a message arrives from the server. Callbacks receive
* the raw message data as an additional argument.
* Triggered when a message arrives from the server.
*/
public onmessage(ev: MessageEvent) {
public onmessage(data: string) {
if (!this._ws) {
// It's possible to receive a message after we disconnect, at least in tests (where
// WebSocket is a node library). Ignoring is easier than unsubscribing properly.
return;
}
this._scheduleHeartbeat();
this._processReceivedMessage(ev.data, true);
this._processReceivedMessage(data, true);
}
public send(message: any) {
@ -317,7 +317,7 @@ export class GristWSConnection extends Disposable {
private _sendHeartbeat() {
this.send(JSON.stringify({
beat: 'alive',
url: G.window.location.href,
url: this._settings.getPageUrl(),
docId: this._assignmentId,
}));
}
@ -352,8 +352,8 @@ export class GristWSConnection extends Disposable {
this._ws.onmessage = this.onmessage.bind(this);
this._ws.onerror = (ev: Event|ErrorEvent) => {
this._log('GristWSConnection: onerror', 'error' in ev ? String(ev.error) : ev);
this._ws.onerror = (err: Error) => {
this._log('GristWSConnection: onerror', String(err));
};
this._ws.onclose = () => {

@ -21,7 +21,6 @@ import {reportWarning} from 'app/client/models/errors';
import {IAppError} from 'app/client/models/NotifyModel';
import {GristLoadConfig} from 'app/common/gristUrls';
import {timeFormat} from 'app/common/timeFormat';
import {ActiveSessionInfo} from 'app/common/UserAPI';
import * as version from 'app/common/version';
import {dom} from 'grainjs';
import identity = require('lodash/identity');
@ -249,7 +248,7 @@ function getBeaconUserObj(appModel: AppModel|null): IUserObj|null {
if (!appModel) { return null; }
// ActiveSessionInfo["user"] includes optional helpScoutSignature too.
const user = appModel.currentValidUser as ActiveSessionInfo["user"]|null;
const user = appModel.currentValidUser;
// For anon user, don't attempt to identify anything. Even the "company" field (when anon on a
// team doc) isn't useful, because the user may be external to the company.

@ -1,7 +1,7 @@
import * as AccountPageModule from 'app/client/ui/AccountPage';
import * as ActivationPageModule from 'app/client/ui/ActivationPage';
import * as BillingPageModule from 'app/client/ui/BillingPage';
import * as SupportGristPageModule from 'app/client/ui/SupportGristPage';
import * as AdminPanelModule from 'app/client/ui/AdminPanel';
import * as GristDocModule from 'app/client/components/GristDoc';
import * as ViewPane from 'app/client/components/ViewPane';
import * as UserManagerModule from 'app/client/ui/UserManager';
@ -15,7 +15,7 @@ export type MomentTimezone = typeof momentTimezone;
export function loadAccountPage(): Promise<typeof AccountPageModule>;
export function loadActivationPage(): Promise<typeof ActivationPageModule>;
export function loadBillingPage(): Promise<typeof BillingPageModule>;
export function loadSupportGristPage(): Promise<typeof SupportGristPageModule>;
export function loadAdminPanel(): Promise<typeof AdminPanelModule>;
export function loadGristDoc(): Promise<typeof GristDocModule>;
export function loadMomentTimezone(): Promise<MomentTimezone>;
export function loadPlotly(): Promise<PlotlyType>;

@ -9,7 +9,7 @@
exports.loadAccountPage = () => import('app/client/ui/AccountPage' /* webpackChunkName: "AccountPage" */);
exports.loadActivationPage = () => import('app/client/ui/ActivationPage' /* webpackChunkName: "ActivationPage" */);
exports.loadBillingPage = () => import('app/client/ui/BillingPage' /* webpackChunkName: "BillingModule" */);
exports.loadSupportGristPage = () => import('app/client/ui/SupportGristPage' /* webpackChunkName: "SupportGristPage" */);
exports.loadAdminPanel = () => import('app/client/ui/AdminPanel' /* webpackChunkName: "AdminPanel" */);
exports.loadGristDoc = () => import('app/client/components/GristDoc' /* webpackChunkName: "GristDoc" */);
// When importing this way, the module is under the "default" member, not sure why (maybe
// esbuild-loader's doing).

@ -25,8 +25,9 @@ import {getDefaultThemePrefs, Theme, ThemeColors, ThemePrefs,
ThemePrefsChecker} from 'app/common/ThemePrefs';
import {getThemeColors} from 'app/common/Themes';
import {getGristConfig} from 'app/common/urlUtils';
import {ExtendedUser} from 'app/common/UserAPI';
import {getOrgName, isTemplatesOrg, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {getUserPrefObs, getUserPrefsObs, markAsSeen, markAsUnSeen} from 'app/client/models/UserPrefs';
import {getUserPrefObs, getUserPrefsObs, markAsSeen} from 'app/client/models/UserPrefs';
import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs';
const t = makeT('AppModel');
@ -40,7 +41,7 @@ export type PageType =
| "billing"
| "welcome"
| "account"
| "support"
| "admin"
| "activation";
const G = getBrowserGlobals('document', 'window');
@ -99,8 +100,8 @@ export interface AppModel {
topAppModel: TopAppModel;
api: UserAPI;
currentUser: FullUser|null;
currentValidUser: FullUser|null; // Like currentUser, but null when anonymous
currentUser: ExtendedUser|null;
currentValidUser: ExtendedUser|null; // Like currentUser, but null when anonymous
currentOrg: Organization|null; // null if no access to currentSubdomain
currentOrgName: string; // Our best guess for human-friendly name.
@ -141,8 +142,8 @@ export interface AppModel {
isSupport(): boolean; // If user is a Support user
isOwner(): boolean; // If user is an owner of this org
isOwnerOrEditor(): boolean; // If user is an owner or editor of this org
/** Creates an computed observable to dismiss a popup or check if it was dismissed */
dismissedPopup(name: DismissedPopup): Observable<boolean>;
isInstallAdmin(): boolean; // Is user an admin of this installation
dismissPopup(name: DismissedPopup, isSeen: boolean): void; // Mark popup as dismissed or not.
switchUser(user: FullUser, org?: string): Promise<void>;
}
@ -283,7 +284,7 @@ export class AppModelImpl extends Disposable implements AppModel {
public readonly api: UserAPI = this.topAppModel.api;
// Compute currentValidUser, turning anonymous into null.
public readonly currentValidUser: FullUser|null =
public readonly currentValidUser: ExtendedUser|null =
this.currentUser && !this.currentUser.anonymous ? this.currentUser : null;
// Figure out the org name, or blank if details are unavailable.
@ -324,8 +325,8 @@ export class AppModelImpl extends Disposable implements AppModel {
return 'welcome';
} else if (state.account) {
return 'account';
} else if (state.supportGrist) {
return 'support';
} else if (state.adminPanel) {
return 'admin';
} else if (state.activation) {
return 'activation';
} else {
@ -340,7 +341,7 @@ export class AppModelImpl extends Disposable implements AppModel {
state.billing === 'scheduled' ||
Boolean(state.account) ||
Boolean(state.activation) ||
Boolean(state.supportGrist)
Boolean(state.adminPanel)
);
});
@ -353,7 +354,7 @@ export class AppModelImpl extends Disposable implements AppModel {
constructor(
public readonly topAppModel: TopAppModel,
public readonly currentUser: FullUser|null,
public readonly currentUser: ExtendedUser|null,
public readonly currentOrg: Organization|null,
public readonly orgError?: OrgError,
) {
@ -419,6 +420,10 @@ export class AppModelImpl extends Disposable implements AppModel {
return Boolean(this.currentOrg && isOwnerOrEditor(this.currentOrg));
}
public isInstallAdmin(): boolean {
return Boolean(this.currentUser?.isInstallAdmin);
}
/**
* Fetch and update the current org's usage.
*/
@ -435,16 +440,8 @@ export class AppModelImpl extends Disposable implements AppModel {
}
}
public dismissedPopup(name: DismissedPopup): Computed<boolean> {
const computed = Computed.create(null, use => use(this.dismissedPopups).includes(name));
computed.onWrite(value => {
if (value) {
markAsSeen(this.dismissedPopups, name);
} else {
markAsUnSeen(this.dismissedPopups, name);
}
});
return computed;
public dismissPopup(name: DismissedPopup, isSeen: boolean): void {
markAsSeen(this.dismissedPopups, name, isSeen);
}
public async switchUser(user: FullUser, org?: string) {

@ -89,12 +89,16 @@ export const {getPrefsObs: getUserPrefsObs, getPrefObs: getUserPrefObs} = makePr
// For preferences that store a list of items (such as seen docTours), this helper updates the
// preference to add itemId to it (e.g. to avoid auto-starting the docTour again in the future).
// prefKey is used only to log a more informative warning on error.
export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T) {
export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T, isSeen = true) {
const seenIds = seenIdsObs.get() || [];
try {
if (!seenIds.includes(itemId)) {
const seen = new Set(seenIds);
seen.add(itemId);
if (isSeen) {
seen.add(itemId);
} else {
seen.delete(itemId);
}
seenIdsObs.set([...seen].sort());
}
} catch (e) {
@ -104,17 +108,3 @@ export function markAsSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T
console.warn("Failed to save preference in markAsSeen", e);
}
}
export function markAsUnSeen<T>(seenIdsObs: Observable<T[] | undefined>, itemId: T) {
const seenIds = seenIdsObs.get() || [];
try {
if (seenIds.includes(itemId)) {
const seen = new Set(seenIds);
seen.delete(itemId);
seenIdsObs.set([...seen].sort());
}
} catch (e) {
// tslint:disable-next-line:no-console
console.warn("Failed to save preference in markAsUnSeen", e);
}
}

@ -171,7 +171,7 @@ export class UrlStateImpl {
public updateState(prevState: IGristUrlState, newState: IGristUrlState): IGristUrlState {
const keepState = (newState.org || newState.ws || newState.homePage || newState.doc || isEmpty(newState) ||
newState.account || newState.billing || newState.activation || newState.welcome ||
newState.supportGrist) ?
newState.adminPanel) ?
(prevState.org ? {org: prevState.org} : {}) :
prevState;
return {...keepState, ...newState};
@ -205,10 +205,10 @@ export class UrlStateImpl {
const signupReload = [prevState.login, newState.login].includes('signup')
&& prevState.login !== newState.login;
// Reload when moving to/from the support Grist page.
const supportGristReload = Boolean(prevState.supportGrist) !== Boolean(newState.supportGrist);
const adminPanelReload = Boolean(prevState.adminPanel) !== Boolean(newState.adminPanel);
return Boolean(orgReload || accountReload || billingReload || activationReload ||
gristConfig.errPage || docReload || welcomeReload || linkKeysReload || signupReload ||
supportGristReload);
adminPanelReload);
}
/**

@ -1,6 +1,7 @@
import {AppModel} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, getSignupUrl, urlState} from 'app/client/models/gristUrlState';
import {getAdminPanelName} from 'app/client/ui/AdminPanel';
import {manageTeamUsers} from 'app/client/ui/OpenUserManager';
import {createUserImage} from 'app/client/ui/UserImage';
import * as viewport from 'app/client/ui/viewport';
@ -146,8 +147,8 @@ export class AccountWidget extends Disposable {
this._maybeBuildBillingPageMenuItem(),
this._maybeBuildActivationPageMenuItem(),
this._maybeBuildSupportGristPageMenuItem(),
this._maybeBuildAdminPanelMenuItem(),
this._maybeBuildSupportGristButton(),
mobileModeToggle,
// TODO Add section ("Here right now") listing icons of other users currently on this doc.
@ -209,26 +210,34 @@ export class AccountWidget extends Disposable {
}
private _maybeBuildActivationPageMenuItem() {
const {activation, deploymentType} = getGristConfig();
if (deploymentType !== 'enterprise' || !activation?.isManager) {
const {deploymentType} = getGristConfig();
if (deploymentType !== 'enterprise' || !this._appModel.isInstallAdmin()) {
return null;
}
return menuItemLink(t('Activation'), urlState().setLinkUrl({activation: 'activation'}));
}
private _maybeBuildSupportGristPageMenuItem() {
const {deploymentType} = getGristConfig();
if (deploymentType !== 'core') {
return null;
private _maybeBuildAdminPanelMenuItem() {
// Only show Admin Panel item to the installation admins.
if (this._appModel.currentUser?.isInstallAdmin) {
return menuItemLink(
getAdminPanelName(),
urlState().setLinkUrl({adminPanel: 'admin'}),
testId('usermenu-admin-panel'),
);
}
}
return menuItemLink(
t('Support Grist'),
cssHeartIcon('💛'),
urlState().setLinkUrl({supportGrist: 'support'}),
testId('usermenu-support-grist'),
);
private _maybeBuildSupportGristButton() {
const {deploymentType} = getGristConfig();
const isEnabled = (deploymentType === 'core') && isFeatureEnabled("supportGrist");
if (isEnabled) {
return menuItemLink(t('Support Grist'), ' 💛',
{href: commonUrls.githubSponsorGristLabs, target: '_blank'},
testId('usermenu-support-grist'),
);
}
}
}
@ -245,10 +254,6 @@ export const cssUserIcon = styled('div', `
cursor: pointer;
`);
const cssHeartIcon = styled('span', `
margin-left: 8px;
`);
const cssUserInfo = styled('div', `
padding: 12px 24px 12px 16px;
min-width: 200px;

@ -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};
`);

@ -167,8 +167,8 @@ export class AppHeader extends Disposable {
}
private _maybeBuildActivationPageMenuItem() {
const {activation, deploymentType} = getGristConfig();
if (deploymentType !== 'enterprise' || !activation?.isManager) {
const {deploymentType} = getGristConfig();
if (deploymentType !== 'enterprise' || !this._appModel.isInstallAdmin()) {
return null;
}

@ -1,7 +1,7 @@
import {buildDocumentBanners, buildHomeBanners} from 'app/client/components/Banners';
import {ViewAsBanner} from 'app/client/components/ViewAsBanner';
import {domAsync} from 'app/client/lib/domAsync';
import {loadAccountPage, loadActivationPage, loadBillingPage, loadSupportGristPage} from 'app/client/lib/imports';
import {loadAccountPage, loadActivationPage, loadAdminPanel, loadBillingPage} from 'app/client/lib/imports';
import {createSessionObs, isBoolean, isNumber} from 'app/client/lib/sessionObs';
import {AppModel, TopAppModel} from 'app/client/models/AppModel';
import {DocPageModelImpl} from 'app/client/models/DocPageModel';
@ -81,8 +81,8 @@ function createMainPage(appModel: AppModel, appObj: App) {
return dom.create(WelcomePage, appModel);
} else if (pageType === 'account') {
return domAsync(loadAccountPage().then(ap => dom.create(ap.AccountPage, appModel)));
} else if (pageType === 'support') {
return domAsync(loadSupportGristPage().then(sgp => dom.create(sgp.SupportGristPage, appModel)));
} else if (pageType === 'admin') {
return domAsync(loadAdminPanel().then(m => dom.create(m.AdminPanel, appModel)));
} else if (pageType === 'activation') {
return domAsync(loadActivationPage().then(ap => dom.create(ap.ActivationPage, appModel)));
} else {

@ -74,12 +74,24 @@ export class DocHistory extends Disposable implements IDomComponent {
docApi.getSnapshots().then(result =>
snapshots.isDisposed() || snapshots.set(result.snapshots)).catch(err => {
snapshotsDenied.set(true);
reportError(err);
// "cannot confirm access" is what we expect if snapshots
// are denied because of access rules.
if (!String(err).match(/cannot confirm access/)) {
reportError(err);
}
});
return dom(
'div',
{tabIndex: '-1'}, // Voodoo needed to allow copying text.
dom.maybe(snapshotsDenied, () => cssSnapshotDenied(
t("Snapshots are unavailable."),
dom(
'p',
t("Snapshots are unavailable."),
),
dom(
'p',
t("Only owners have access to snapshots for documents with access rules."),
),
testId('doc-history-error'))),
// Note that most recent snapshots are first.
dom.domComputed(snapshots, (snapshotList) => snapshotList.map((snapshot, index) => {
@ -128,6 +140,8 @@ const cssSnapshot = styled('div', `
const cssSnapshotDenied = styled('div', `
margin: 8px 16px;
text-align: center;
color: ${theme.text};
`);
const cssSnapshotTime = styled('div', `

@ -180,7 +180,7 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
// manage card popups will be needed if more are added later.
return [
upgradeButton.showUpgradeCard(css.upgradeCard.cls('')),
home.app.supportGristNudge.showCard(),
home.app.supportGristNudge.buildNudgeCard(),
];
}),
));

@ -5,6 +5,7 @@ import {reportError} from 'app/client/models/AppModel';
import {docUrl, urlState} from 'app/client/models/gristUrlState';
import {HomeModel} from 'app/client/models/HomeModel';
import {getWorkspaceInfo, workspaceName} from 'app/client/models/WorkspaceInfo';
import {getAdminPanelName} from 'app/client/ui/AdminPanel';
import {addNewButton, cssAddNewButton} from 'app/client/ui/AddNewButton';
import {docImport, importFromPlugin} from 'app/client/ui/HomeImports';
import {
@ -136,6 +137,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
),
),
createVideoTourToolsButton(),
(home.app.isInstallAdmin() ?
cssPageEntry(
cssPageLink(cssPageIcon('Settings'), cssLinkText(getAdminPanelName()),
urlState().setLinkUrl({adminPanel: "admin"}),
testId('dm-admin-panel'),
),
) : null
),
createHelpTools(home.app),
)
)

@ -5,15 +5,13 @@ import {tokenFieldStyles} from 'app/client/lib/TokenField';
import {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars';
import {basicButton, basicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, isNarrowScreenObs, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {commonUrls} from 'app/common/gristUrls';
import {commonUrls, isFeatureEnabled} from 'app/common/gristUrls';
import {getGristConfig} from 'app/common/urlUtils';
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
const testId = makeTestId('test-support-grist-nudge-');
import {Computed, Disposable, dom, DomContents, Observable, styled, UseCB} from 'grainjs';
const t = makeT('SupportGristNudge');
@ -21,115 +19,93 @@ type ButtonState =
| 'collapsed'
| 'expanded';
type CardPage =
| 'support'
| 'opted-in';
/**
* Nudges users to support Grist by opting in to telemetry.
* Nudges users to support Grist by opting in to telemetry or sponsoring on Github.
*
* For installation admins, this includes a card with a nudge which collapses into a "Support
* Grist" button in the top bar. When that's not applicable, it is only a "Support Grist" button
* that links to the Github sponsorship page.
*
* This currently includes a button that opens a card with the nudge.
* The button is hidden when the card is visible, and vice versa.
* Users can dismiss these nudges.
*/
export class SupportGristNudge extends Disposable {
private readonly _telemetryModel: TelemetryModel = new TelemetryModelImpl(this._appModel);
private readonly _buttonState: Observable<ButtonState>;
private readonly _currentPage: Observable<CardPage>;
private readonly _isClosed: Observable<boolean>;
constructor(private _appModel: AppModel) {
super();
if (!this._shouldShowCardOrButton()) { return; }
this._buttonState = localStorageObs(
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`, 'expanded'
) as Observable<ButtonState>;
this._currentPage = Observable.create(null, 'support');
this._isClosed = Observable.create(this, false);
}
private readonly _telemetryModel: TelemetryModel = TelemetryModelImpl.create(this, this._appModel);
public showButton() {
if (!this._shouldShowCardOrButton()) { return null; }
private readonly _buttonStateKey = `u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`;
private _buttonState = localStorageObs(this._buttonStateKey, 'expanded') as Observable<ButtonState>;
return dom.maybe(
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'collapsed' && !use(this._isClosed)),
() => this._buildButton()
);
}
// Whether the nudge just got accepted, and we should temporarily show the "Thanks" version.
private _justAccepted = Observable.create(this, false);
public showCard() {
if (!this._shouldShowCardOrButton()) { return null; }
private _showButton: Computed<null|'link'|'expand'>;
private _showNudge: Computed<null|'normal'|'accepted'>;
return dom.maybe(
use => !use(isNarrowScreenObs()) && (use(this._buttonState) === 'expanded' && !use(this._isClosed)),
() => this._buildCard()
constructor(private _appModel: AppModel) {
super();
const {deploymentType, telemetry} = getGristConfig();
const isEnabled = (deploymentType === 'core') && isFeatureEnabled("supportGrist");
const isAdmin = _appModel.isInstallAdmin();
const isTelemetryOn = (telemetry && telemetry.telemetryLevel !== 'off');
const isAdminNudgeApplicable = isAdmin && !isTelemetryOn;
const generallyHide = (use: UseCB) => (
!isEnabled ||
use(_appModel.dismissedPopups).includes('supportGrist') ||
use(isNarrowScreenObs())
);
}
private _markAsDismissed() {
this._appModel.dismissedPopup('supportGrist').set(true);
getStorage().removeItem(
`u=${this._appModel.currentValidUser?.id ?? 0};supportGristNudge`);
}
private _close() {
this._isClosed.set(true);
}
private _dismissAndClose() {
this._markAsDismissed();
this._close();
}
private _shouldShowCardOrButton() {
if (this._appModel.dismissedPopups.get().includes('supportGrist')) {
return false;
}
const {activation, deploymentType, telemetry} = getGristConfig();
if (deploymentType !== 'core' || !activation?.isManager) {
return false;
}
if (telemetry && telemetry.telemetryLevel !== 'off') {
return false;
}
return true;
this._showButton = Computed.create(this, use => {
if (generallyHide(use)) { return null; }
if (!isAdminNudgeApplicable) { return 'link'; }
if (use(this._buttonState) !== 'expanded') { return 'expand'; }
return null;
});
this._showNudge = Computed.create(this, use => {
if (use(this._justAccepted)) { return 'accepted'; }
if (generallyHide(use)) { return null; }
if (isAdminNudgeApplicable && use(this._buttonState) === 'expanded') { return 'normal'; }
return null;
});
}
private _buildButton() {
return cssContributeButton(
cssButtonIconAndText(
icon('Fireworks'),
t('Contribute'),
),
cssContributeButtonCloseButton(
icon('CrossSmall'),
dom.on('click', (ev) => {
ev.stopPropagation();
this._dismissAndClose();
}),
testId('contribute-button-close'),
),
dom.on('click', () => { this._buttonState.set('expanded'); }),
testId('contribute-button'),
);
public buildTopBarButton(): DomContents {
return dom.domComputed(this._showButton, (which) => {
if (!which) { return null; }
const elemType = (which === 'link') ? basicButtonLink : basicButton;
return cssContributeButton(
elemType(cssHeartIcon('💛 '), t('Support Grist'),
(which === 'link' ?
{href: commonUrls.githubSponsorGristLabs, target: '_blank'} :
dom.on('click', () => this._buttonState.set('expanded'))
),
cssContributeButtonCloseButton(
icon('CrossSmall'),
dom.on('click', (ev) => {
ev.stopPropagation();
ev.preventDefault();
this._dismissAndClose();
}),
testId('support-grist-button-dismiss'),
),
testId('support-grist-button'),
)
);
});
}
private _buildCard() {
return cssCard(
dom.domComputed(this._currentPage, page => {
if (page === 'support') {
return this._buildSupportGristCardContent();
} else {
return this._buildOptedInCardContent();
}
}),
testId('card'),
);
public buildNudgeCard() {
return dom.domComputed(this._showNudge, nudge => {
if (!nudge) { return null; }
return cssCard(
(nudge === 'normal' ?
this._buildSupportGristCardContent() :
this._buildOptedInCardContent()
),
testId('support-nudge'),
);
});
}
private _buildSupportGristCardContent() {
@ -137,7 +113,7 @@ export class SupportGristNudge extends Disposable {
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._buttonState.set('collapsed')),
testId('card-close'),
testId('support-nudge-close'),
),
cssLeftAlignedHeader(t('Support Grist')),
cssParagraph(t(
@ -150,14 +126,14 @@ export class SupportGristNudge extends Disposable {
'document contents. Opt out any time from the {{supportGristLink}} in the user menu.',
{
helpCenterLink: helpCenterLink(),
supportGristLink: supportGristLink(),
supportGristLink: adminPanelLink(),
},
),
),
cssFullWidthButton(
t('Opt in to Telemetry'),
dom.on('click', () => this._optInToTelemetry()),
testId('card-opt-in'),
testId('support-nudge-opt-in'),
),
];
}
@ -166,8 +142,8 @@ export class SupportGristNudge extends Disposable {
return [
cssCloseButton(
icon('CrossBig'),
dom.on('click', () => this._close()),
testId('card-close-icon-button'),
dom.on('click', () => this._justAccepted.set(false)),
testId('support-nudge-close'),
),
cssCenteredFlex(cssSparks()),
cssCenterAlignedHeader(t('Opted In')),
@ -175,23 +151,34 @@ export class SupportGristNudge extends Disposable {
t(
'Thank you! Your trust and support is greatly appreciated.\
Opt out any time from the {{link}} in the user menu.',
{link: supportGristLink()},
{link: adminPanelLink()},
),
),
cssCenteredFlex(
cssPrimaryButton(
t('Close'),
dom.on('click', () => this._close()),
testId('card-close-button'),
dom.on('click', () => this._justAccepted.set(false)),
testId('support-nudge-close-button'),
),
),
];
}
private _markDismissed() {
this._appModel.dismissPopup('supportGrist', true);
// Also cleanup the no-longer-needed button state from localStorage.
getStorage().removeItem(this._buttonStateKey);
}
private _dismissAndClose() {
this._markDismissed();
this._justAccepted.set(false);
}
private async _optInToTelemetry() {
await this._telemetryModel.updateTelemetryPrefs({telemetryLevel: 'limited'});
this._currentPage.set('opted-in');
this._markAsDismissed();
this._markDismissed();
this._justAccepted.set(true);
}
}
@ -202,10 +189,10 @@ function helpCenterLink() {
);
}
function supportGristLink() {
function adminPanelLink() {
return cssLink(
t('Support Grist page'),
{href: urlState().makeUrl({supportGrist: 'support'}), target: '_blank'},
t('Admin Panel'),
{href: urlState().makeUrl({adminPanel: 'admin'}), target: '_blank'},
);
}
@ -216,26 +203,8 @@ const cssCenteredFlex = styled('div', `
`);
const cssContributeButton = styled('div', `
position: relative;
background: ${theme.controlPrimaryBg};
color: ${theme.controlPrimaryFg};
border-radius: 25px;
padding: 4px 12px 4px 8px;
font-style: normal;
font-weight: medium;
font-size: 13px;
line-height: 16px;
cursor: pointer;
--icon-color: ${theme.controlPrimaryFg};
&:hover {
background: ${theme.controlPrimaryHoverBg};
}
`);
const cssButtonIconAndText = styled('div', `
display: flex;
gap: 8px;
margin-left: 8px;
margin-right: 8px;
`);
const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton, `
@ -254,10 +223,14 @@ const cssContributeButtonCloseButton = styled(tokenFieldStyles.cssDeleteButton,
display: none;
align-items: center;
justify-content: center;
--icon-color: ${colors.light};
.${cssContributeButton.className}:hover & {
display: flex;
}
&:hover {
--icon-color: ${colors.lightGreen};
}
`);
const cssCard = styled('div', `
@ -325,3 +298,8 @@ const cssSparks = styled('div', `
display: inline-block;
background-repeat: no-repeat;
`);
// This is just to avoid the emoji pushing the button to be taller.
const cssHeartIcon = styled('span', `
line-height: 1;
`);

@ -1,29 +1,20 @@
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 {TelemetryModel, TelemetryModelImpl} from 'app/client/models/TelemetryModel';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
import {pagePanels} from 'app/client/ui/PagePanels';
import {createTopBarHome} from 'app/client/ui/TopBar';
import {cssBreadcrumbs, separator} from 'app/client/ui2018/breadcrumbs';
import {bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {mediaSmall, theme, vars} from 'app/client/ui2018/cssVars';
import {basicButtonLink, bigBasicButton, bigBasicButtonLink, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {cssLink} from 'app/client/ui2018/links';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
import {commonUrls} from 'app/common/gristUrls';
import {TelemetryPrefsWithSources} from 'app/common/InstallAPI';
import {getGristConfig} from 'app/common/urlUtils';
import {Computed, Disposable, dom, makeTestId, Observable, styled, subscribe} from 'grainjs';
import {Computed, Disposable, dom, makeTestId, styled} from 'grainjs';
const testId = makeTestId('test-support-grist-page-');
const t = makeT('SupportGristPage');
export class SupportGristPage extends Disposable {
private readonly _currentPage = Computed.create(this, urlState().state, (_use, s) => s.supportGrist);
private readonly _model: TelemetryModel = new TelemetryModelImpl(this._appModel);
private readonly _optInToTelemetry = Computed.create(this, this._model.prefs,
(_use, prefs) => {
@ -38,62 +29,19 @@ export class SupportGristPage extends Disposable {
constructor(private _appModel: AppModel) {
super();
this._setPageTitle();
this._model.fetchTelemetryPrefs().catch(reportError);
}
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: this._buildMainContent(),
});
}
private _buildMainHeader() {
return dom.frag(
cssBreadcrumbs({style: 'margin-left: 16px;'},
cssLink(
urlState().setLinkUrl({}),
t('Home'),
),
separator(' / '),
dom('span', t('Support Grist')),
),
createTopBarHome(this._appModel),
);
}
private _buildMainContent() {
return cssPageContainer(
cssPage(
dom('div',
cssPageTitle(t('Support Grist')),
this._buildTelemetrySection(),
this._buildSponsorshipSection(),
),
),
);
}
private _buildTelemetrySection() {
public buildTelemetrySection() {
return cssSection(
cssSectionTitle(t('Telemetry')),
dom.domComputed(this._model.prefs, prefs => {
if (prefs === null) {
return cssSpinnerBox(loadingSpinner());
}
const {activation} = getGristConfig();
if (!activation?.isManager) {
if (!this._appModel.isInstallAdmin()) {
// TODO: We are no longer serving this page to non-admin users, so this branch should no
// longer match, and this version perhaps should be removed.
if (prefs.telemetryLevel.value === 'limited') {
return [
cssParagraph(t(
@ -127,7 +75,9 @@ export class SupportGristPage extends Disposable {
);
}
private _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) {
public getTelemetryOptInObservable() { return this._optInToTelemetry; }
public _buildTelemetrySectionButtons(prefs: TelemetryPrefsWithSources) {
const {telemetryLevel: {value, source}} = prefs;
if (source === 'preferences') {
return dom.domComputed(this._optInToTelemetry, (optedIn) => {
@ -159,9 +109,8 @@ export class SupportGristPage extends Disposable {
}
}
private _buildSponsorshipSection() {
public buildSponsorshipSection() {
return cssSection(
cssSectionTitle(t('Sponsor Grist Labs on GitHub')),
cssParagraph(
t(
'Grist software is developed by Grist Labs, which offers free and paid ' +
@ -189,16 +138,9 @@ export class SupportGristPage extends Disposable {
);
}
private _setPageTitle() {
this.autoDispose(subscribe(this._currentPage, (_use, page): string => {
const suffix = getPageTitleSuffix(getGristConfig());
switch (page) {
case undefined:
case 'support': {
return document.title = `Support Grist${suffix}`;
}
}
}));
public buildSponsorshipSmallButton() {
return basicButtonLink('💛 ', t('Sponsor'),
{href: commonUrls.githubSponsorGristLabs, target: '_blank'});
}
}
@ -223,44 +165,7 @@ function gristCoreLink() {
);
}
const cssPageContainer = styled('div', `
overflow: auto;
padding: 64px 80px;
@media ${mediaSmall} {
& {
padding: 0px;
}
}
`);
const cssPage = styled('div', `
padding: 16px;
max-width: 600px;
width: 100%;
`);
const cssPageTitle = styled('div', `
height: 32px;
line-height: 32px;
margin-bottom: 24px;
color: ${theme.text};
font-size: 24px;
font-weight: ${vars.headerControlTextWeight};
`);
const cssSectionTitle = styled('div', `
height: 24px;
line-height: 24px;
margin-bottom: 24px;
color: ${theme.text};
font-size: ${vars.xlargeFontSize};
font-weight: ${vars.headerControlTextWeight};
`);
const cssSection = styled('div', `
margin-bottom: 60px;
`);
const cssSection = styled('div', ``);
const cssParagraph = styled('div', `
color: ${theme.text};

@ -28,7 +28,6 @@ export function createTopBarHome(appModel: AppModel) {
return [
cssFlexSpace(),
appModel.supportGristNudge.showButton(),
(appModel.isTeamSite && roles.canEditAccess(appModel.currentOrg?.access || null) ?
[
basicButton(
@ -41,6 +40,8 @@ export function createTopBarHome(appModel: AppModel) {
null
),
appModel.supportGristNudge.buildTopBarButton(),
buildLanguageMenu(appModel),
isAnonymous ? null : buildNotifyMenuButton(appModel.notifier, appModel),
dom('div', dom.create(AccountWidget, appModel)),

@ -15,13 +15,11 @@ export function buildTutorialCard(owner: IDisposableOwner, options: Options) {
if (!isFeatureEnabled('tutorials')) { return null; }
const {app} = options;
const dismissed = app.dismissedPopup('tutorialFirstCard');
owner.autoDispose(dismissed);
function onClose() {
dismissed.set(true);
app.dismissPopup('tutorialFirstCard', true);
}
const visible = Computed.create(owner, (use) =>
!use(dismissed)
!use(app.dismissedPopups).includes('tutorialFirstCard')
&& !use(isNarrowScreenObs())
);
return dom.maybe(visible, () => {

@ -243,8 +243,13 @@ export function getUserRoleText(user: UserAccessData) {
return roleNames[user.access!] || user.access || 'no access';
}
export interface ExtendedUser extends FullUser {
helpScoutSignature?: string;
isInstallAdmin?: boolean; // Set if user is allowed to manage this installation.
}
export interface ActiveSessionInfo {
user: FullUser & {helpScoutSignature?: string};
user: ExtendedUser;
org: Organization|null;
orgError?: OrgError;
}

@ -44,8 +44,8 @@ export type ActivationPage = typeof ActivationPage.type;
export const LoginPage = StringUnion('signup', 'login', 'verified', 'forgot-password');
export type LoginPage = typeof LoginPage.type;
export const SupportGristPage = StringUnion('support');
export type SupportGristPage = typeof SupportGristPage.type;
export const AdminPanelPage = StringUnion('admin');
export type AdminPanelPage = typeof AdminPanelPage.type;
// Overall UI style. "full" is normal, "singlePage" is a single page focused, panels hidden experience.
export const InterfaceStyle = StringUnion('singlePage', 'full');
@ -124,7 +124,7 @@ export interface IGristUrlState {
activation?: ActivationPage;
login?: LoginPage;
welcome?: WelcomePage;
supportGrist?: SupportGristPage;
adminPanel?: AdminPanelPage;
welcomeTour?: boolean;
docTour?: boolean;
manageUsers?: boolean;
@ -309,7 +309,7 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
parts.push(`welcome/${state.welcome}`);
}
if (state.supportGrist) { parts.push(state.supportGrist); }
if (state.adminPanel) { parts.push(state.adminPanel); }
const queryParams = pickBy(state.params, (v, k) => k !== 'linkParameters') as {[key: string]: string};
for (const [k, v] of Object.entries(state.params?.linkParameters || {})) {
@ -415,7 +415,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
// the minimum length of a urlId prefix is longer than the maximum length
// of any of the valid keys in the url.
for (const key of map.keys()) {
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key) && !SupportGristPage.guard(key)) {
if (key.length >= MIN_URLID_PREFIX_LENGTH && !LoginPage.guard(key)) {
map.set('doc', key);
map.set('slug', map.get(key)!);
map.delete(key);
@ -453,9 +453,7 @@ export function decodeUrl(gristConfig: Partial<GristLoadConfig>, location: Locat
state.activation = ActivationPage.parse(map.get('activation')) || 'activation';
}
if (map.has('welcome')) { state.welcome = WelcomePage.parse(map.get('welcome')); }
if (map.has('support')) {
state.supportGrist = SupportGristPage.parse(map.get('support')) || 'support';
}
if (map.has('admin')) { state.adminPanel = AdminPanelPage.parse(map.get('admin')) || 'admin'; }
if (sp.has('planType')) { state.params!.planType = sp.get('planType')!; }
if (sp.has('billingPlan')) { state.params!.billingPlan = sp.get('billingPlan')!; }
if (sp.has('billingTask')) {
@ -740,7 +738,7 @@ export interface GristLoadConfig {
// Google Tag Manager id. Currently only used to load tag manager for reporting new sign-ups.
tagManagerId?: string;
activation?: Activation;
activation?: ActivationState;
// List of enabled features.
features?: IFeature[];
@ -799,6 +797,7 @@ export const Features = StringUnion(
"multiAccounts",
"sendToDrive",
"tutorials",
"supportGrist",
);
export type IFeature = typeof Features.type;
@ -834,10 +833,6 @@ export interface ActivationState {
}
}
export interface Activation extends ActivationState {
isManager: boolean;
}
// Acceptable org subdomains are alphanumeric (hyphen also allowed) and of
// non-zero length.
const subdomainRegex = /^[-a-z0-9]+$/i;

@ -527,7 +527,10 @@ export class ApiServer {
)) : null;
const orgError = (org && org.errMessage) ? {error: org.errMessage, status: org.status} : undefined;
return sendOkReply(req, res, {
user: {...fullUser, helpScoutSignature: helpScoutSign(fullUser.email)},
user: {...fullUser,
helpScoutSignature: helpScoutSign(fullUser.email),
isInstallAdmin: await this._gristServer.getInstallAdmin().isAdminReq(req) || undefined,
},
org: (org && org.data) || null,
orgError
});

@ -1715,7 +1715,7 @@ export class ActiveDoc extends EventEmitter {
public async getSnapshots(docSession: OptDocSession, skipMetadataCache?: boolean): Promise<DocSnapshots> {
if (await this._granularAccess.hasNuancedAccess(docSession)) {
throw new Error('cannot confirm access to snapshots');
throw new Error('cannot confirm access to snapshots because of access rules');
}
return this._docManager.storageManager.getSnapshots(this.docName, skipMetadataCache);
}

@ -22,7 +22,7 @@ import {fromCallback} from 'app/server/lib/serverUtils';
import {i18n} from 'i18next';
import * as crypto from 'crypto';
import moment from 'moment';
import * as WebSocket from 'ws';
import {GristServerSocket} from 'app/server/lib/GristServerSocket';
// How many messages and bytes to accumulate for a disconnected client before booting it.
// The benefit is that a client who temporarily disconnects and reconnects without missing much,
@ -97,8 +97,7 @@ export class Client {
private _missedMessagesTotalLength: number = 0;
private _destroyTimer: NodeJS.Timer|null = null;
private _destroyed: boolean = false;
private _websocket: WebSocket|null;
private _websocketEventHandlers: Array<{event: string, handler: (...args: any[]) => void}> = [];
private _websocket: GristServerSocket|null;
private _org: string|null = null;
private _profile: UserProfile|null = null;
private _user: FullUser|undefined = undefined;
@ -131,18 +130,14 @@ export class Client {
return this._locale;
}
public setConnection(websocket: WebSocket, counter: string|null, browserSettings: BrowserSettings) {
public setConnection(websocket: GristServerSocket, counter: string|null, browserSettings: BrowserSettings) {
this._websocket = websocket;
this._counter = counter;
this.browserSettings = browserSettings;
const addHandler = (event: string, handler: (...args: any[]) => void) => {
websocket.on(event, handler);
this._websocketEventHandlers.push({event, handler});
};
addHandler('error', (err: unknown) => this._onError(err));
addHandler('close', () => this._onClose());
addHandler('message', (msg: string) => this._onMessage(msg));
websocket.onerror = (err: Error) => this._onError(err);
websocket.onclose = () => this._onClose();
websocket.onmessage = (msg: string) => this._onMessage(msg);
}
/**
@ -189,7 +184,7 @@ export class Client {
public interruptConnection() {
if (this._websocket) {
this._removeWebsocketListeners();
this._websocket.removeAllListeners();
this._websocket.terminate(); // close() is inadequate when ws routed via loadbalancer
this._websocket = null;
}
@ -359,7 +354,7 @@ export class Client {
// See also my report at https://stackoverflow.com/a/48411315/328565
await delay(250);
if (!this._destroyed && this._websocket?.readyState === WebSocket.OPEN) {
if (!this._destroyed && this._websocket?.isOpen) {
await this._sendToWebsocket(JSON.stringify({...clientConnectMsg, dup: true}));
}
} catch (err) {
@ -604,7 +599,7 @@ export class Client {
/**
* Processes an error on the websocket.
*/
private _onError(err: unknown) {
private _onError(err: Error) {
this._log.warn(null, "onError", err);
// TODO Make sure that this is followed by onClose when the connection is lost.
}
@ -613,7 +608,7 @@ export class Client {
* Processes the closing of a websocket.
*/
private _onClose() {
this._removeWebsocketListeners();
this._websocket?.removeAllListeners();
// Remove all references to the websocket.
this._websocket = null;
@ -629,15 +624,4 @@ export class Client {
this._destroyTimer = setTimeout(() => this.destroy(), Deps.clientRemovalTimeoutMs);
}
}
private _removeWebsocketListeners() {
if (this._websocket) {
// Avoiding websocket.removeAllListeners() because WebSocket.Server registers listeners
// internally for websockets it keeps track of, and we should not accidentally remove those.
for (const {event, handler} of this._websocketEventHandlers) {
this._websocket.off(event, handler);
}
this._websocketEventHandlers = [];
}
}
}

@ -35,7 +35,8 @@
import {EventEmitter} from 'events';
import * as http from 'http';
import * as https from 'https';
import * as WebSocket from 'ws';
import {GristSocketServer} from 'app/server/lib/GristSocketServer';
import {GristServerSocket} from 'app/server/lib/GristServerSocket';
import {parseFirstUrlPart} from 'app/common/gristUrls';
import {firstDefined, safeJsonParse} from 'app/common/gutil';
@ -50,6 +51,7 @@ import {localeFromRequest} from 'app/server/lib/ServerLocale';
import {fromCallback} from 'app/server/lib/serverUtils';
import {Sessions} from 'app/server/lib/Sessions';
import {i18n} from 'i18next';
import { trustOrigin } from './requestUtils';
export interface CommOptions {
sessions: Sessions; // A collection of all sessions for this instance of Grist
@ -74,7 +76,7 @@ export interface CommOptions {
export class Comm extends EventEmitter {
// Collection of all sessions; maps sessionIds to ScopedSession objects.
public readonly sessions: Sessions = this._options.sessions;
private _wss: WebSocket.Server[]|null = null;
private _wss: GristSocketServer[]|null = null;
private _clients = new Map<string, Client>(); // Maps clientIds to Client objects.
@ -146,11 +148,6 @@ export class Comm extends EventEmitter {
public async testServerShutdown() {
if (this._wss) {
for (const wssi of this._wss) {
// Terminate all clients. WebSocket.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 wssi.clients) {
ws.terminate();
}
await fromCallback((cb) => wssi.close(cb));
}
this._wss = null;
@ -204,14 +201,7 @@ export class Comm extends EventEmitter {
/**
* Processes a new websocket connection, and associates the websocket and a Client object.
*/
private async _onWebSocketConnection(websocket: WebSocket, req: http.IncomingMessage) {
if (this._options.hosts) {
// DocWorker ID (/dw/) and version tag (/v/) may be present in this request but are not
// needed. addOrgInfo assumes req.url starts with /o/ if present.
req.url = parseFirstUrlPart('dw', req.url!).path;
req.url = parseFirstUrlPart('v', req.url).path;
await this._options.hosts.addOrgInfo(req);
}
private async _onWebSocketConnection(websocket: GristServerSocket, req: http.IncomingMessage) {
// Parse the cookie in the request to get the sessionId.
const sessionId = this.sessions.getSessionIdFromRequest(req);
@ -255,15 +245,28 @@ export class Comm extends EventEmitter {
if (this._options.httpsServer) { servers.push(this._options.httpsServer); }
const wss = [];
for (const server of servers) {
const wssi = new WebSocket.Server({server});
wssi.on('connection', async (websocket: WebSocket, req) => {
const wssi = new GristSocketServer(server, {
verifyClient: async (req: http.IncomingMessage) => {
if (this._options.hosts) {
// DocWorker ID (/dw/) and version tag (/v/) may be present in this request but are not
// needed. addOrgInfo assumes req.url starts with /o/ if present.
req.url = parseFirstUrlPart('dw', req.url!).path;
req.url = parseFirstUrlPart('v', req.url).path;
await this._options.hosts.addOrgInfo(req);
}
return trustOrigin(req);
}
});
wssi.onconnection = async (websocket: GristServerSocket, req) => {
try {
await this._onWebSocketConnection(websocket, req);
} catch (e) {
log.error("Comm connection for %s threw exception: %s", req.url, e.message);
websocket.terminate(); // close() is inadequate when ws routed via loadbalancer
}
});
};
wss.push(wssi);
}
return wss;

@ -48,6 +48,7 @@ import {HostedStorageManager} from 'app/server/lib/HostedStorageManager';
import {IBilling} from 'app/server/lib/IBilling';
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
import {INotifier} from 'app/server/lib/INotifier';
import {InstallAdmin} from 'app/server/lib/InstallAdmin';
import log from 'app/server/lib/log';
import {getLoginSystem} from 'app/server/lib/logins';
import {IPermitStore} from 'app/server/lib/Permit';
@ -55,7 +56,7 @@ import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/place
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
import {PluginManager} from 'app/server/lib/PluginManager';
import * as ProcessMonitor from 'app/server/lib/ProcessMonitor';
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isDefaultUser, isParameterOn, optIntegerParam,
import {adaptServerUrl, getOrgUrl, getOriginUrl, getScope, integerParam, isParameterOn, optIntegerParam,
optStringParam, RequestWithGristInfo, sendOkReply, stringArrayParam, stringParam, TEST_HTTPS_OFFSET,
trustOrigin} from 'app/server/lib/requestUtils';
import {ISendAppPageOptions, makeGristConfig, makeMessagePage, makeSendAppPage} from 'app/server/lib/sendAppPage';
@ -133,6 +134,7 @@ export class FlexServer implements GristServer {
private _servesPlugins?: boolean;
private _bundledWidgets?: ICustomWidget[];
private _billing: IBilling;
private _installAdmin: InstallAdmin;
private _instanceRoot: string;
private _docManager: DocManager;
private _docWorker: DocWorker;
@ -390,6 +392,11 @@ export class FlexServer implements GristServer {
return this._notifier;
}
public getInstallAdmin(): InstallAdmin {
if (!this._installAdmin) { throw new Error('no InstallAdmin available'); }
return this._installAdmin;
}
public getAccessTokens() {
if (this._accessTokens) { return this._accessTokens; }
this.addDocWorkerMap();
@ -725,6 +732,7 @@ export class FlexServer implements GristServer {
// If the installation appears to be new, give it an id and a creation date.
this._activations = new Activations(this._dbManager);
await this._activations.current();
this._installAdmin = await this.create.createInstallAdmin(this._dbManager);
}
public addDocWorkerMap() {
@ -864,11 +872,6 @@ export class FlexServer implements GristServer {
this._telemetry = this.create.Telemetry(this._dbManager, this);
this._telemetry.addEndpoints(this.app);
this._telemetry.addPages(this.app, [
this._redirectToHostMiddleware,
this._userIdMiddleware,
this._redirectToLoginWithoutExceptionsMiddleware,
]);
await this._telemetry.start();
// Start up a monitor for memory and cpu usage.
@ -1787,15 +1790,23 @@ export class FlexServer implements GristServer {
public addInstallEndpoints() {
if (this._check('install')) { return; }
const isManager = expressWrap(
(req: express.Request, _res: express.Response, next: express.NextFunction) => {
if (!isDefaultUser(req)) { throw new ApiError('Access denied', 403); }
const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin();
next();
}
);
const adminPageMiddleware = [
this._redirectToHostMiddleware,
this._userIdMiddleware,
this._redirectToLoginWithoutExceptionsMiddleware,
// In principle, it may be safe to show the Admin Panel to non-admins but let's protect it
// since it's intended for admins, and it's easier not to have to worry how it should behave
// for others.
requireInstallAdmin,
];
this.app.get('/admin', ...adminPageMiddleware, expressWrap(async (req, resp) => {
return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
}));
this.app.get('/api/install/prefs', expressWrap(async (_req, resp) => {
// Restrict this endpoint to install admins too, for the same reason as the /admin page.
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
const activation = await this._activations.current();
return sendOkReply(null, resp, {
@ -1803,7 +1814,7 @@ export class FlexServer implements GristServer {
});
}));
this.app.patch('/api/install/prefs', isManager, expressWrap(async (req, resp) => {
this.app.patch('/api/install/prefs', requireInstallAdmin, expressWrap(async (req, resp) => {
const props = {prefs: req.body};
const activation = await this._activations.current();
activation.checkProperties(props);

@ -16,6 +16,7 @@ import { Hosts } from 'app/server/lib/extractOrg';
import { ICreate } from 'app/server/lib/ICreate';
import { IDocStorageManager } from 'app/server/lib/IDocStorageManager';
import { INotifier } from 'app/server/lib/INotifier';
import { InstallAdmin } from 'app/server/lib/InstallAdmin';
import { IPermitStore } from 'app/server/lib/Permit';
import { ISendAppPageOptions } from 'app/server/lib/sendAppPage';
import { fromCallback } from 'app/server/lib/serverUtils';
@ -47,6 +48,7 @@ export interface GristServer {
getDeploymentType(): GristDeploymentType;
getHosts(): Hosts;
getActivations(): Activations;
getInstallAdmin(): InstallAdmin;
getHomeDBManager(): HomeDBManager;
getStorageManager(): IDocStorageManager;
getTelemetry(): ITelemetry;
@ -135,6 +137,7 @@ export function createDummyGristServer(): GristServer {
getDeploymentType() { return 'core'; },
getHosts() { throw new Error('no hosts'); },
getActivations() { throw new Error('no activations'); },
getInstallAdmin() { throw new Error('no install admin'); },
getHomeDBManager() { throw new Error('no db'); },
getStorageManager() { throw new Error('no storage manager'); },
getTelemetry() { return createDummyTelemetry(); },
@ -155,7 +158,6 @@ export function createDummyGristServer(): GristServer {
export function createDummyTelemetry(): ITelemetry {
return {
addEndpoints() { /* do nothing */ },
addPages() { /* do nothing */ },
start() { return Promise.resolve(); },
logEvent() { /* do nothing */ },
logEventAsync() { return Promise.resolve(); },

@ -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);
}
}

@ -6,6 +6,7 @@ import {ExternalStorage} from 'app/server/lib/ExternalStorage';
import {createDummyTelemetry, GristServer} from 'app/server/lib/GristServer';
import {IBilling} from 'app/server/lib/IBilling';
import {INotifier} from 'app/server/lib/INotifier';
import {InstallAdmin, SimpleInstallAdmin} from 'app/server/lib/InstallAdmin';
import {ISandbox, ISandboxCreationOptions} from 'app/server/lib/ISandbox';
import {IShell} from 'app/server/lib/IShell';
import {createSandbox, SpawnFn} from 'app/server/lib/NSandbox';
@ -28,6 +29,9 @@ export interface ICreate {
NSandbox(options: ISandboxCreationOptions): ISandbox;
// Create the logic to determine which users are authorized to manage this Grist installation.
createInstallAdmin(dbManager: HomeDBManager): Promise<InstallAdmin>;
deploymentType(): GristDeploymentType;
sessionSecret(): string;
// Check configuration of the app early enough to show on startup.
@ -80,6 +84,7 @@ export function makeSimpleCreator(opts: {
getExtraHeadHtml?: () => string,
getSqliteVariant?: () => SqliteVariant,
getSandboxVariants?: () => Record<string, SpawnFn>,
createInstallAdmin?: (dbManager: HomeDBManager) => Promise<InstallAdmin>,
}): ICreate {
const {deploymentType, sessionSecret, storage, notifier, billing, telemetry} = opts;
return {
@ -157,5 +162,6 @@ export function makeSimpleCreator(opts: {
},
getSqliteVariant: opts.getSqliteVariant,
getSandboxVariants: opts.getSandboxVariants,
createInstallAdmin: opts.createInstallAdmin || (async () => new SimpleInstallAdmin()),
};
}

@ -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;
}
}

@ -58,7 +58,6 @@ export interface ITelemetry {
): Promise<void>;
shouldLogEvent(name: TelemetryEvent): boolean;
addEndpoints(app: express.Express): void;
addPages(app: express.Express, middleware: express.RequestHandler[]): void;
getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined;
fetchTelemetryPrefs(): Promise<void>;
}
@ -196,15 +195,6 @@ export class Telemetry implements ITelemetry {
}));
}
public addPages(app: express.Application, middleware: express.RequestHandler[]) {
if (this._deploymentType === 'core') {
app.get('/support', ...middleware, expressWrap(async (req, resp) => {
return this._gristServer.sendAppPage(req, resp,
{path: 'app.html', status: 200, config: {}});
}));
}
}
public getTelemetryConfig(requestOrSession?: RequestOrSession): TelemetryConfig | undefined {
const prefs = this._telemetryPrefs;
if (!prefs) {

@ -2,13 +2,15 @@ import {ApiError} from 'app/common/ApiError';
import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
import * as gutil from 'app/common/gutil';
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
import {getUser, getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {RequestWithGrist} from 'app/server/lib/GristServer';
import log from 'app/server/lib/log';
import {Permit} from 'app/server/lib/Permit';
import {Request, Response} from 'express';
import { IncomingMessage } from 'http';
import {Writable} from 'stream';
import { TLSSocket } from 'tls';
// log api details outside of dev environment (when GRIST_HOSTED_VERSION is set)
const shouldLogApiDetails = Boolean(process.env.GRIST_HOSTED_VERSION);
@ -75,33 +77,33 @@ export function getOrgUrl(req: Request, path: string = '/') {
}
/**
* Returns true for requests from permitted origins. For such requests, an
* "Access-Control-Allow-Origin" header is added to the response. Vary: Origin
* is also set to reflect the fact that the headers are a function of the origin,
* to prevent inappropriate caching on the browser's side.
* Returns true for requests from permitted origins. For such requests, if
* a Response object is provided, an "Access-Control-Allow-Origin" header is added
* to the response. Vary: Origin is also set to reflect the fact that the headers
* are a function of the origin, to prevent inappropriate caching on the browser's side.
*/
export function trustOrigin(req: Request, resp: Response): boolean {
export function trustOrigin(req: IncomingMessage, resp?: Response): boolean {
// TODO: We may want to consider changing allowed origin values in the future.
// Note that the request origin is undefined for non-CORS requests.
const origin = req.get('origin');
const origin = req.headers.origin;
if (!origin) { return true; } // Not a CORS request.
if (process.env.GRIST_HOST && req.hostname === process.env.GRIST_HOST) { return true; }
if (!allowHost(req, new URL(origin))) { return false; }
// For a request to a custom domain, the full hostname must match.
resp.header("Access-Control-Allow-Origin", origin);
resp.header("Vary", "Origin");
if (resp) {
// For a request to a custom domain, the full hostname must match.
resp.header("Access-Control-Allow-Origin", origin);
resp.header("Vary", "Origin");
}
return true;
}
// Returns whether req satisfies the given allowedHost. Unless req is to a custom domain, it is
// enough if only the base domains match. Differing ports are allowed, which helps in dev/testing.
export function allowHost(req: Request, allowedHost: string|URL) {
const mreq = req as RequestWithOrg;
export function allowHost(req: IncomingMessage, allowedHost: string|URL) {
const proto = getEndUserProtocol(req);
const actualUrl = new URL(getOriginUrl(req));
const allowedUrl = (typeof allowedHost === 'string') ? new URL(`${proto}://${allowedHost}`) : allowedHost;
if (mreq.isCustomHost) {
if ((req as RequestWithOrg).isCustomHost) {
// For a request to a custom domain, the full hostname must match.
return actualUrl.hostname === allowedUrl.hostname;
} else {
@ -330,8 +332,8 @@ export interface RequestWithGristInfo extends Request {
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto
* https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/x-forwarded-headers.html
*/
export function getOriginUrl(req: Request) {
const host = req.get('host')!;
export function getOriginUrl(req: IncomingMessage) {
const host = req.headers.host;
const protocol = getEndUserProtocol(req);
return `${protocol}://${host}`;
}
@ -342,11 +344,13 @@ export function getOriginUrl(req: Request) {
* otherwise X-Forwarded-Proto is set on the provided request, otherwise
* the protocol of the request itself.
*/
export function getEndUserProtocol(req: Request) {
export function getEndUserProtocol(req: IncomingMessage) {
if (process.env.APP_HOME_URL) {
return new URL(process.env.APP_HOME_URL).protocol.replace(':', '');
}
return req.get("X-Forwarded-Proto") || req.protocol;
// TODO we shouldn't blindly trust X-Forwarded-Proto. See the Express approach:
// https://expressjs.com/en/5x/api.html#trust.proxy.options.table
return req.headers["x-forwarded-proto"] || ((req.socket as TLSSocket).encrypted ? 'https' : 'http');
}
/**
@ -377,9 +381,3 @@ export function addAbortHandler(req: Request, res: Writable, op: () => void) {
}
});
}
export function isDefaultUser(req: Request) {
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
const {loginEmail} = getUser(req);
return defaultEmail && defaultEmail === loginEmail;
}

@ -82,7 +82,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
((server?.getBundledWidgets().length || 0) > 0),
survey: Boolean(process.env.DOC_ID_NEW_USER_INFO),
tagManagerId: process.env.GOOGLE_TAG_MANAGER_ID,
activation: getActivation(req as RequestWithLogin | undefined),
activation: (req as RequestWithLogin|undefined)?.activation,
enableCustomCss: isAffirmative(process.env.APP_STATIC_INCLUDE_CUSTOM_CSS),
supportedLngs: readLoadedLngs(req?.i18n),
namespaces: readLoadedNamespaces(req?.i18n),
@ -306,11 +306,3 @@ function getDocFromConfig(config: GristLoadConfig): Document | null {
return config.getDoc[config.assignmentId] ?? null;
}
function getActivation(mreq: RequestWithLogin|undefined) {
const defaultEmail = process.env.GRIST_DEFAULT_EMAIL;
return {
...mreq?.activation,
isManager: Boolean(defaultEmail && defaultEmail === mreq?.user?.loginEmail),
};
}

@ -82,7 +82,7 @@
"@types/tmp": "0.0.33",
"@types/uuid": "3.4.4",
"@types/which": "2.0.1",
"@types/ws": "^6",
"@types/ws": "^8",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"app-module-path": "2.2.0",
@ -139,6 +139,8 @@
"diff-match-patch": "1.0.5",
"dompurify": "3.0.6",
"double-ended-queue": "2.1.0-0",
"engine.io": "^6.5.4",
"engine.io-client": "^6.5.3",
"exceljs": "4.2.1",
"express": "4.18.2",
"file-type": "16.5.4",

@ -225,7 +225,8 @@
"Compare to Previous": "Compare to Previous",
"Open Snapshot": "Open Snapshot",
"Snapshots": "Snapshots",
"Snapshots are unavailable.": "Snapshots are unavailable."
"Snapshots are unavailable.": "Snapshots are unavailable.",
"Only owners have access to snapshots for documents with access rules.": "Only owners have access to snapshots for documents with access rules."
},
"DocMenu": {
"(The organization needs a paid plan)": "(The organization needs a paid plan)",
@ -1280,7 +1281,8 @@
"Opted In": "Opted In",
"Support Grist": "Support Grist",
"Support Grist page": "Support Grist page",
"Thank you! Your trust and support is greatly appreciated.Opt out any time from the {{link}} in the user menu.": "Thank you! Your trust and support is greatly appreciated.Opt out any time from the {{link}} in the user menu."
"Thank you! Your trust and support is greatly appreciated.Opt out any time from the {{link}} in the user menu.": "Thank you! Your trust and support is greatly appreciated.Opt out any time from the {{link}} in the user menu.",
"Admin Panel": "Admin Panel"
},
"SupportGristPage": {
"GitHub": "GitHub",
@ -1298,7 +1300,8 @@
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "We only collect usage statistics, as detailed in our {{link}}, never document contents.",
"You can opt out of telemetry at any time from this page.": "You can opt out of telemetry at any time from this page.",
"You have opted in to telemetry. Thank you!": "You have opted in to telemetry. Thank you!",
"You have opted out of telemetry.": "You have opted out of telemetry."
"You have opted out of telemetry.": "You have opted out of telemetry.",
"Sponsor": "Sponsor"
},
"buildViewSectionDom": {
"No data": "No data",
@ -1437,5 +1440,17 @@
"Team url": "Team url",
"Work as a Team": "Work as a Team",
"Billing is not supported in grist-core": "Billing is not supported in grist-core"
},
"AdminPanel": {
"Admin Panel": "Admin Panel",
"Current": "Current",
"Current version of Grist": "Current version of Grist",
"Help us make Grist better": "Help us make Grist better",
"Home": "Home",
"Sponsor": "Sponsor",
"Support Grist": "Support Grist",
"Support Grist Labs on GitHub": "Support Grist Labs on GitHub",
"Telemetry": "Telemetry",
"Version": "Version"
}
}

@ -293,7 +293,12 @@
"Save Document": "Сохранить документ",
"Work on a Copy": "Работа над копией",
"Share": "Поделиться",
"Download...": "Скачать..."
"Download...": "Скачать...",
"Comma Separated Values (.csv)": "Значения, разделенные запятыми (.csv)",
"Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
"Tab Separated Values (.tsv)": "Значения, разделенные табуляцией (.tsv)",
"DOO Separated Values (.dsv)": "DOO Разделенные значения (.dsv)",
"Export as...": "Экспортировать как..."
},
"SortConfig": {
"Search Columns": "Поиск по столбцам",
@ -635,7 +640,8 @@
"Insert row": "Вставить строку",
"Insert row above": "Вставить строку выше",
"Delete": "Удалить",
"View as card": "Посмотреть как карточку"
"View as card": "Посмотреть как карточку",
"Use as table headers": "Использовать в качестве заголовков таблицы"
},
"RecordLayout": {
"Updating record layout.": "Обновление макета записи."
@ -716,7 +722,9 @@
"Submission": "Отправка",
"Submit another response": "Отправить другой ответ",
"Submit button label": "Название кнопки отправки",
"Success text": "Текст успешной отправки"
"Success text": "Текст успешной отправки",
"No field selected": "Нет выбранных полей",
"Select a field in the form widget to configure.": "Выберите поле в виджете формы для настройки."
},
"PermissionsWidget": {
"Allow All": "Разрешить все",
@ -1338,7 +1346,21 @@
"Publish": "Публиковать",
"Publish your form?": "Опубликовать форму?",
"Unpublish": "Отменить публикацию",
"Unpublish your form?": "Отменить публикацию формы?"
"Unpublish your form?": "Отменить публикацию формы?",
"Code copied to clipboard": "Код скопирован в буфер обмена",
"Copy code": "Скопировать код",
"Embed this form": "Встроить эту форму",
"Link copied to clipboard": "Ссылка скопирована в буфер обмена",
"Preview": "Предпросмотр",
"Save your document to publish this form.": "Сохраните документ, для публикации этой формы.",
"Share": "Поделиться",
"Share this form": "Поделиться этой формой",
"View": "Просмотр",
"Anyone with the link below can see the empty form and submit a response.": "Любой, у кого есть ссылка ниже, может увидеть пустую форму и отправить ответ.",
"Are you sure you want to reset your form?": "Вы уверены, что хотите сбросить форму?",
"Copy link": "Копировать ссылку",
"Reset": "Сброс",
"Reset form": "Сброс формы"
},
"WelcomeCoachingCall": {
"free coaching call": "бесплатный коуч-звонок",
@ -1387,5 +1409,17 @@
"This month": "Этот месяц",
"This week": "Эта неделя",
"This year": "Текущий год"
},
"MappedFieldsConfig": {
"Clear": "Очистить",
"Map fields": "Сопоставить поля",
"Mapped": "Сопоставлено",
"Select All": "Выбрать все",
"Unmap fields": "Отменить сопоставление полей",
"Unmapped": "Несопоставленные"
},
"Section": {
"Insert section above": "Вставить секцию выше",
"Insert section below": "Вставить секцию ниже"
}
}

@ -613,7 +613,9 @@
"Enter text": "Vnesi besedilo",
"Redirect automatically after submission": "Po oddaji samodejno preusmeri",
"Enter redirect URL": "Vnesi preusmeritveni URL",
"Submit button label": "Oznaka gumba za pošiljanje"
"Submit button label": "Oznaka gumba za pošiljanje",
"Select a field in the form widget to configure.": "Izberi polje v gradniku obrazca, ki ga želiš konfigurirati.",
"No field selected": "Nobeno polje ni izbrano"
},
"FloatingPopup": {
"Maximize": "Povečajte",
@ -898,7 +900,12 @@
"Unsaved": "Neshranjeno",
"Save Document": "Shrani dokument",
"Save Copy": "Shrani kopijo",
"Return to {{termToUse}}": "Vrnitev na {{termToUse}}"
"Return to {{termToUse}}": "Vrnitev na {{termToUse}}",
"Export as...": "Izvozi kot ...",
"Comma Separated Values (.csv)": "Vrednosti, ločene z vejicami (.csv)",
"DOO Separated Values (.dsv)": "DOO ločene vrednosti (.dsv)",
"Microsoft Excel (.xlsx)": "Microsoft Excel (.xlsx)",
"Tab Separated Values (.tsv)": "Vrednosti, ločene s tabulatorji (.tsv)"
},
"NotifyUI": {
"Go to your free personal site": "Pojdite na brezplačno osebno spletno mesto",
@ -1318,7 +1325,21 @@
"Publish": "Objavi",
"Unpublish your form?": "Želiš preklicati objavo obrazca?",
"Publish your form?": "Želiš objaviti obrazec?",
"Unpublish": "Prekliči objavo"
"Unpublish": "Prekliči objavo",
"Link copied to clipboard": "Povezava je bila kopirana v odložišče",
"Preview": "Predogled",
"Reset": "Ponastaviti",
"Save your document to publish this form.": "Shrani dokument, da objaviš ta obrazec.",
"Share this form": "Deli ta dokument",
"View": "Pogled",
"Anyone with the link below can see the empty form and submit a response.": "Vsakdo s spodnjo povezavo si lahko ogleda prazen obrazec in odda odgovor.",
"Are you sure you want to reset your form?": "Ali si prepričan, da želiš ponastaviti obrazec?",
"Code copied to clipboard": "Koda je bila kopirana v odložišče",
"Copy code": "Kopiraj kodo",
"Copy link": "Kopiraj povezavo",
"Embed this form": "Vdelaj ta obrazec",
"Reset form": "Ponastavi obrazec",
"Share": "Deli"
},
"Menu": {
"Building blocks": "Gradniki",
@ -1388,5 +1409,17 @@
"This week": "Ta teden",
"This year": "To leto",
"Today": "Danes"
},
"MappedFieldsConfig": {
"Clear": "Počisti",
"Unmapped": "Nepreslikan",
"Map fields": "Preslikaj polja",
"Mapped": "Preslikan",
"Select All": "Izberi vse",
"Unmap fields": "Nepreslikana polja"
},
"Section": {
"Insert section above": "Vstavi razdelek zgoraj",
"Insert section below": "Vstavite razdelek spodaj"
}
}

File diff suppressed because it is too large Load Diff

@ -31,7 +31,8 @@ if (!process.env.GRIST_SINGLE_ORG) {
setDefaultEnv('GRIST_ORG_IN_PATH', 'true');
}
setDefaultEnv('GRIST_UI_FEATURES', 'helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive,createSite');
setDefaultEnv('GRIST_UI_FEATURES',
'helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive,createSite,supportGrist');
setDefaultEnv('GRIST_WIDGET_LIST_URL', commonUrls.gristLabsWidgetRepository);
import {updateDb} from 'app/server/lib/dbUtils';
import {main as mergedServerMain, parseServerTypes} from 'app/server/mergedServerMain';

@ -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);

@ -5,6 +5,8 @@ import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import * as testUtils from 'test/server/testUtils';
const sponsorshipUrl = 'https://github.com/sponsors/gristlabs';
describe('SupportGrist', function() {
this.timeout(30000);
setupTestSuite();
@ -14,6 +16,10 @@ describe('SupportGrist', function() {
afterEach(() => gu.checkForErrors());
after(async function() {
await server.restart();
});
describe('in grist-core', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
@ -24,133 +30,66 @@ describe('SupportGrist', function() {
after(async function() {
oldEnv.restore();
await server.restart();
});
describe('when user is not a manager', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
await server.restart();
session = await gu.session().user('user2').personalSite.login();
await session.loadDocMenu('/');
});
after(async function() {
oldEnv.restore();
});
it('does not show a nudge on the doc menu', async function() {
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
await assertSupportButtonShown(true, {isSponsorLink: true});
});
it('shows a link to the Support Grist page in the user menu', async function() {
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click();
assert.isTrue(await driver.findContentWait(
'.test-support-grist-page-sponsorship-section',
/Sponsor Grist Labs on GitHub/,
4000
).isDisplayed());
});
it('shows a message that telemetry is managed by the site administrator', async function() {
assert.isTrue(await driver.findContentWait(
'.test-support-grist-page-telemetry-section',
/This instance is opted out of telemetry\. Only the site administrator has permission to change this\./,
4000
).isDisplayed());
process.env.GRIST_TELEMETRY_LEVEL = 'limited';
await server.restart();
await driver.navigate().refresh();
assert.isTrue(await driver.findContentWait(
'.test-support-grist-page-telemetry-section',
/This instance is opted in to telemetry\. Only the site administrator has permission to change this\./,
4000
).isDisplayed());
await assertMenuHasAdminPanel(false);
await assertMenuHasSupportGrist(true);
});
});
describe('when user is a manager', function() {
before(async function() {
oldEnv = new testUtils.EnvironmentSnapshot();
await server.restart();
session = await gu.session().personalSite.login();
await session.loadDocMenu('/');
});
after(async function() {
oldEnv.restore();
});
it('shows a nudge on the doc menu', async function() {
// Check that the nudge is expanded by default.
await assertNudgeButtonShown(false);
await assertSupportButtonShown(false);
await assertNudgeCardShown(true);
// Reload the doc menu and check that it's still expanded.
await session.loadDocMenu('/');
await assertNudgeButtonShown(false);
await assertSupportButtonShown(false);
await assertNudgeCardShown(true);
// Close the nudge and check that it's now collapsed.
await driver.find('.test-support-grist-nudge-card-close').click();
await assertNudgeButtonShown(true);
await driver.find('.test-support-nudge-close').click();
await assertSupportButtonShown(true, {isSponsorLink: false});
await assertNudgeCardShown(false);
// Reload again, and check that it's still collapsed.
await session.loadDocMenu('/');
await assertNudgeButtonShown(true);
await assertSupportButtonShown(true, {isSponsorLink: false});
await assertNudgeCardShown(false);
// Dismiss the contribute button and check that it's now gone, even after reloading.
await driver.find('.test-support-grist-nudge-contribute-button').mouseMove();
await driver.find('.test-support-grist-nudge-contribute-button-close').click();
await assertNudgeButtonShown(false);
await driver.find('.test-support-grist-button').mouseMove();
await driver.find('.test-support-grist-button-dismiss').click();
await assertSupportButtonShown(false);
await assertNudgeCardShown(false);
await session.loadDocMenu('/');
await assertNudgeButtonShown(false);
await assertSupportButtonShown(false);
await assertNudgeCardShown(false);
});
it('shows a link to the Support Grist page in the user menu', async function() {
it('shows a link to Admin Panel and Support Grist in the user menu', async function() {
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').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());
});
it('supports opting in to telemetry from the page', async function() {
await assertTelemetryLevel('off');
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! 🙏'
);
// Reload the page and check that the Grist config indicates telemetry is set to "limited".
await driver.navigate().refresh();
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());
// Reload the page and check that the Grist config indicates telemetry is set to "off".
await driver.navigate().refresh();
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');
await assertMenuHasAdminPanel(true);
await assertMenuHasSupportGrist(true);
});
it('supports opting in to telemetry from the nudge', async function() {
@ -160,14 +99,14 @@ describe('SupportGrist', function() {
await session.loadDocMenu('/');
// Opt in to telemetry and reload the page.
await driver.find('.test-support-grist-nudge-card-opt-in').click();
await driver.findWait('.test-support-grist-nudge-card-close-button', 1000).click();
await assertNudgeButtonShown(false);
await driver.find('.test-support-nudge-opt-in').click();
await driver.findWait('.test-support-nudge-close-button', 1000).click();
await assertSupportButtonShown(false);
await assertNudgeCardShown(false);
await session.loadDocMenu('/');
// Check that the nudge is no longer shown and telemetry is set to "limited".
await assertNudgeButtonShown(false);
await assertSupportButtonShown(false);
await assertNudgeCardShown(false);
await assertTelemetryLevel('limited');
});
@ -179,52 +118,55 @@ describe('SupportGrist', function() {
// Reload the doc menu and check that the nudge still isn't shown.
await session.loadDocMenu('/');
await assertNudgeButtonShown(false);
await assertNudgeCardShown(false);
// We still show the "Support Grist" button linking to sponsorship page.
await assertSupportButtonShown(true, {isSponsorLink: true});
// Disable telemetry from the Support Grist page.
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click();
await driver.find('.test-usermenu-admin-panel').click();
await driver.findWait('.test-admin-panel', 2000);
await driver.find('.test-admin-panel-item-name-telemetry').click();
await driver.sleep(500); // Wait for section to expand.
await driver.findContentWait(
'.test-support-grist-page-telemetry-section button', /Opt out of Telemetry/, 2000).click();
await driver.findContentWait('.test-support-grist-page-telemetry-section button', /Opt in to Telemetry/, 2000);
// Reload the doc menu and check that the nudge is now shown.
await gu.loadDocMenu('/');
await assertNudgeButtonShown(false);
await assertSupportButtonShown(false);
await assertNudgeCardShown(true);
});
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();
it('shows sponsorship link when no telemetry nudge, and allows dismissing it', async function() {
// Reset all dismissed popups, including the telemetry nudge.
await driver.executeScript('resetDismissedPopups();');
await gu.waitForServer();
// Opt in to telemetry
const api = session.createHomeApi();
await api.testRequest(`${api.getBaseUrl()}/api/install/prefs`, {
method: 'patch',
body: JSON.stringify({telemetry: {telemetryLevel: 'limited'}}),
});
// Check that the Support Grist page reports telemetry is enabled.
await gu.loadDocMenu('/');
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click();
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 gu.loadDocMenu('/');
await gu.openAccountMenu();
await driver.find('.test-usermenu-support-grist').click();
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());
await session.loadDocMenu('/');
await assertTelemetryLevel('limited');
// We still show the "Support Grist" button linking to sponsorship page.
await assertSupportButtonShown(true, {isSponsorLink: true});
await assertNudgeCardShown(false);
// we can dismiss it.
await driver.find('.test-support-grist-button').mouseMove();
await driver.find('.test-support-grist-button-dismiss').click();
await assertSupportButtonShown(false);
// And this will get remembered.
await session.loadDocMenu('/');
await assertNudgeCardShown(false);
await assertSupportButtonShown(false);
});
});
});
@ -241,17 +183,25 @@ describe('SupportGrist', function() {
after(async function() {
oldEnv.restore();
await server.restart();
});
it('does not show a nudge on the doc menu', async function() {
await assertNudgeButtonShown(false);
await assertSupportButtonShown(false);
await assertNudgeCardShown(false);
});
it('does not show a link to the Support Grist page in the user menu', async function() {
it('shows Admin Panel but not Support Grist in the user menu for admin', async function() {
await gu.openAccountMenu();
await assertMenuHasAdminPanel(true);
await assertMenuHasSupportGrist(false);
});
it('does not show Admin Panel or Support Grist in the user menu for non-admin', async function() {
session = await gu.session().user('user2').personalSite.login();
await session.loadDocMenu('/');
await gu.openAccountMenu();
assert.isFalse(await driver.find('.test-usermenu-support-grist').isPresent());
await assertMenuHasAdminPanel(false);
await assertMenuHasSupportGrist(false);
});
});
@ -267,38 +217,49 @@ describe('SupportGrist', function() {
after(async function() {
oldEnv.restore();
await server.restart();
});
it('does not show a nudge on the doc menu', async function() {
await assertNudgeButtonShown(false);
await assertSupportButtonShown(false);
await assertNudgeCardShown(false);
});
it('does not show a link to the Support Grist page in the user menu', async function() {
it('shows Admin Panel but not Support Grist page in the user menu', async function() {
await gu.openAccountMenu();
assert.isFalse(await driver.find('.test-usermenu-support-grist').isPresent());
await assertMenuHasAdminPanel(true);
await assertMenuHasSupportGrist(false);
});
});
});
async function assertNudgeButtonShown(isShown: boolean) {
async function assertSupportButtonShown(isShown: false): Promise<void>;
async function assertSupportButtonShown(isShown: true, opts: {isSponsorLink: boolean}): Promise<void>;
async function assertSupportButtonShown(isShown: boolean, opts?: {isSponsorLink: boolean}) {
const button = driver.find('.test-support-grist-button');
assert.equal(await button.isPresent() && await button.isDisplayed(), isShown);
if (isShown) {
assert.isTrue(
await driver.find('.test-support-grist-nudge-contribute-button').isDisplayed()
);
} else {
assert.isFalse(await driver.find('.test-support-grist-nudge-contribute-button').isPresent());
assert.equal(await button.getAttribute('href'), opts?.isSponsorLink ? sponsorshipUrl : null);
}
}
async function assertNudgeCardShown(isShown: boolean) {
const card = driver.find('.test-support-nudge');
assert.equal(await card.isPresent() && await card.isDisplayed(), isShown);
}
async function assertMenuHasAdminPanel(isShown: boolean) {
const elem = driver.find('.test-usermenu-admin-panel');
assert.equal(await elem.isPresent() && await elem.isDisplayed(), isShown);
if (isShown) {
assert.match(await elem.getAttribute('href'), /.*\/admin$/);
}
}
async function assertMenuHasSupportGrist(isShown: boolean) {
const elem = driver.find('.test-usermenu-support-grist');
assert.equal(await elem.isPresent() && await elem.isDisplayed(), isShown);
if (isShown) {
assert.isTrue(
await driver.find('.test-support-grist-nudge-card').isDisplayed()
);
} else {
assert.isFalse(await driver.find('.test-support-grist-nudge-card').isPresent());
assert.equal(await elem.getAttribute('href'), sponsorshipUrl);
}
}

@ -67,6 +67,9 @@ export class TestServerMerged extends EventEmitter implements IMochaServer {
this._starts++;
const workerIdText = process.env.MOCHA_WORKER_ID || '0';
if (reset) {
// Make sure this test server doesn't keep using the DB that's about to disappear.
await this.closeDatabase();
if (process.env.TESTDIR) {
this.testDir = path.join(process.env.TESTDIR, workerIdText);
} else {

@ -4,11 +4,11 @@ import {assert} from 'chai';
import * as http from 'http';
import {AddressInfo} from 'net';
import * as sinon from 'sinon';
import WebSocket from 'ws';
import * as path from 'path';
import * as tmp from 'tmp';
import {GristWSConnection, GristWSSettings} from 'app/client/components/GristWSConnection';
import {GristClientSocket, GristClientSocketOptions} from 'app/client/components/GristClientSocket';
import {Comm as ClientComm} from 'app/client/components/Comm';
import * as log from 'app/client/lib/log';
import {Comm} from 'app/server/lib/Comm';
@ -21,10 +21,28 @@ import {Sessions} from 'app/server/lib/Sessions';
import {TcpForwarder} from 'test/server/tcpForwarder';
import * as testUtils from 'test/server/testUtils';
import * as session from '@gristlabs/express-session';
import { Hosts, RequestOrgInfo } from 'app/server/lib/extractOrg';
const SQLiteStore = require('@gristlabs/connect-sqlite3')(session);
promisifyAll(SQLiteStore.prototype);
// Just enough implementation of Hosts to be able to fake using a custom host.
class FakeHosts {
public isCustomHost = false;
public get asHosts() { return this as unknown as Hosts; }
public async addOrgInfo<T extends http.IncomingMessage>(req: T): Promise<T & RequestOrgInfo> {
return Object.assign(req, {
isCustomHost: this.isCustomHost,
org: "example",
url: req.url!
});
}
}
describe('Comm', function() {
testUtils.setTmpLogLevel(process.env.VERBOSE ? 'debug' : 'warn');
@ -34,6 +52,7 @@ describe('Comm', function() {
let server: http.Server;
let sessions: Sessions;
let fakeHosts: FakeHosts;
let comm: Comm|null = null;
const sandbox = sinon.createSandbox();
@ -51,14 +70,19 @@ describe('Comm', function() {
function startComm(methods: {[name: string]: ClientMethod}) {
server = http.createServer();
comm = new Comm(server, {sessions});
fakeHosts = new FakeHosts();
comm = new Comm(server, {sessions, hosts: fakeHosts.asHosts});
comm.registerMethods(methods);
return listenPromise(server.listen(0, 'localhost'));
}
async function stopComm() {
comm?.destroyAllClients();
return fromCallback(cb => server.close(cb));
await comm?.testServerShutdown();
await fromCallback(cb => {
server.close(cb);
server.closeAllConnections();
});
}
const assortedMethods: {[name: string]: ClientMethod} = {
@ -95,34 +119,43 @@ describe('Comm', function() {
sandbox.restore();
});
function getMessages(ws: WebSocket, count: number): Promise<any[]> {
function getMessages(ws: GristClientSocket, count: number): Promise<any[]> {
return new Promise((resolve, reject) => {
const messages: object[] = [];
ws.on('error', reject);
ws.on('message', (msg: string) => {
messages.push(JSON.parse(msg));
ws.onerror = (err) => {
ws.onmessage = null;
reject(err);
};
ws.onmessage = (data: string) => {
messages.push(JSON.parse(data));
if (messages.length >= count) {
ws.onerror = null;
ws.onmessage = null;
resolve(messages);
ws.removeListener('error', reject);
ws.removeAllListeners('message');
}
});
};
});
}
/**
* Returns a promise for the connected websocket.
*/
function connect() {
const ws = new WebSocket('ws://localhost:' + (server.address() as AddressInfo).port);
return new Promise<WebSocket>((resolve, reject) => {
ws.on('open', () => resolve(ws));
ws.on('error', reject);
function connect(options?: GristClientSocketOptions): Promise<GristClientSocket> {
const ws = new GristClientSocket('ws://localhost:' + (server.address() as AddressInfo).port, options);
return new Promise<GristClientSocket>((resolve, reject) => {
ws.onopen = () => {
ws.onerror = null;
resolve(ws);
};
ws.onerror = (err) => {
ws.onopen = null;
reject(err);
};
});
}
describe("server methods", function() {
let ws: WebSocket;
let ws: GristClientSocket;
beforeEach(async function() {
await startComm(assortedMethods);
ws = await connect();
@ -370,7 +403,8 @@ describe('Comm', function() {
// Intercept the call to _onClose to know when it occurs, since we are trying to hit a
// situation where 'close' and 'failedSend' events happen in either order.
const stubOnClose = sandbox.stub(Client.prototype as any, '_onClose')
.callsFake(function(this: Client) {
.callsFake(async function(this: Client) {
if (!options.closeHappensFirst) { await delay(10); }
eventsSeen.push('close');
return (stubOnClose as any).wrappedMethod.apply(this, arguments);
});
@ -462,6 +496,9 @@ describe('Comm', function() {
if (options.useSmallMsgs) {
assert.deepEqual(eventsSeen, ['close']);
} else {
// Make sure to have waited long enough for the 'close' event we may have delayed
await delay(20);
// Large messages now cause a send to fail, after filling up buffer, and close the socket.
assert.deepEqual(eventsSeen, ['close', 'close']);
}
@ -490,17 +527,54 @@ describe('Comm', function() {
assert.deepEqual(eventSpy.getCalls().map(call => call.args[0].n), [n - 1]);
}
});
describe("Allowed Origin", function() {
beforeEach(async function () {
await startComm(assortedMethods);
});
afterEach(async function() {
await stopComm();
});
async function checkOrigin(headers: { origin: string, host: string }, allowed: boolean) {
const promise = connect({ headers });
if (allowed) {
await assert.isFulfilled(promise, `${headers.host} should allow ${headers.origin}`);
} else {
await assert.isRejected(promise, /.*/, `${headers.host} should reject ${headers.origin}`);
}
}
it('origin should match base domain of host', async () => {
await checkOrigin({origin: "https://www.toto.com", host: "worker.example.com"}, false);
await checkOrigin({origin: "https://badexample.com", host: "worker.example.com"}, false);
await checkOrigin({origin: "https://bad.com/example.com", host: "worker.example.com"}, false);
await checkOrigin({origin: "https://front.example.com", host: "worker.example.com"}, true);
await checkOrigin({origin: "https://front.example.com:3000", host: "worker.example.com"}, true);
await checkOrigin({origin: "https://example.com", host: "example.com"}, true);
});
it('with custom domains, origin should match the full hostname', async () => {
fakeHosts.isCustomHost = true;
// For a request to a custom domain, the full hostname must match.
await checkOrigin({origin: "https://front.example.com", host: "worker.example.com"}, false);
await checkOrigin({origin: "https://front.example.com", host: "front.example.com"}, true);
await checkOrigin({origin: "https://front.example.com:3000", host: "front.example.com"}, true);
});
});
});
// Waits for condFunc() to return true, for up to timeoutMs milliseconds, sleeping for stepMs
// between checks. Returns true if succeeded, false if failed.
async function waitForCondition(condFunc: () => boolean, timeoutMs = 1000, stepMs = 10): Promise<boolean> {
// between checks. Returns if succeeded, throws if failed.
async function waitForCondition(condFunc: () => boolean, timeoutMs = 1000, stepMs = 10): Promise<void> {
const end = Date.now() + timeoutMs;
while (Date.now() < end) {
if (condFunc()) { return true; }
if (condFunc()) { return; }
await delay(stepMs);
}
return false;
throw new Error(`Condition not met after ${timeoutMs}ms: ${condFunc.toString()}`);
}
// Returns a range of count consecutive numbers starting with start.
@ -513,7 +587,7 @@ function getWSSettings(docWorkerUrl: string): GristWSSettings {
let clientId: string = 'clientid-abc';
let counter: number = 0;
return {
makeWebSocket(url: string): any { return new WebSocket(url, undefined, {}); },
makeWebSocket(url: string): any { return new GristClientSocket(url); },
async getTimezone() { return 'UTC'; },
getPageUrl() { return "http://localhost"; },
async getDocWorkerUrl() { return docWorkerUrl; },

@ -4,7 +4,7 @@ import { SchemaTypes } from 'app/common/schema';
import { FlexServer } from 'app/server/lib/FlexServer';
import axios from 'axios';
import pick = require('lodash/pick');
import WebSocket from 'ws';
import {GristClientSocket} from 'app/client/components/GristClientSocket';
interface GristRequest {
reqId: number;
@ -34,9 +34,9 @@ export class GristClient {
private _consumer: () => void;
private _ignoreTrivialActions: boolean = false;
constructor(public ws: any) {
ws.onmessage = (data: any) => {
const msg = pick(JSON.parse(data.data),
constructor(public ws: GristClientSocket) {
ws.onmessage = (data: string) => {
const msg = pick(JSON.parse(data),
['reqId', 'error', 'errorCode', 'data', 'type', 'docFD']);
if (this._ignoreTrivialActions && msg.type === 'docUserAction' &&
msg.data?.actionGroup?.internal === true &&
@ -149,7 +149,6 @@ export class GristClient {
}
public async close() {
this.ws.terminate();
this.ws.close();
}
@ -180,17 +179,19 @@ export async function openClient(server: FlexServer, email: string, org: string,
} else {
headers[emailHeader] = email;
}
const ws = new WebSocket('ws://localhost:' + server.getOwnPort() + `/o/${org}`, {
const ws = new GristClientSocket('ws://localhost:' + server.getOwnPort() + `/o/${org}`, {
headers
});
const client = new GristClient(ws);
await new Promise(function(resolve, reject) {
ws.on('open', function() {
ws.onopen = function() {
ws.onerror = null;
resolve(ws);
});
ws.on('error', function(err: any) {
};
ws.onerror = function(err: Error) {
ws.onopen = null;
reject(err);
});
};
});
return client;
}

@ -4879,11 +4879,17 @@ function testDocApi() {
delete chimpyConfig.headers["X-Requested-With"];
delete anonConfig.headers["X-Requested-With"];
// Target a more realistic Host than "localhost:port"
anonConfig.headers.Host = chimpyConfig.headers.Host = 'api.example.com';
const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;
const data = {records: [{fields: {}}]};
const data = { records: [{ fields: {} }] };
const allowedOrigin = 'http://front.example.com';
const forbiddenOrigin = 'http://evil.com';
// Normal same origin requests
anonConfig.headers.Origin = serverUrl;
anonConfig.headers.Origin = allowedOrigin;
let response: AxiosResponse;
for (response of [
await axios.post(url, data, anonConfig),
@ -4893,13 +4899,13 @@ function testDocApi() {
assert.equal(response.status, 200);
assert.equal(response.headers['access-control-allow-methods'], 'GET, PATCH, PUT, POST, DELETE, OPTIONS');
assert.equal(response.headers['access-control-allow-headers'], 'Authorization, Content-Type, X-Requested-With');
assert.equal(response.headers['access-control-allow-origin'], serverUrl);
assert.equal(response.headers['access-control-allow-origin'], allowedOrigin);
assert.equal(response.headers['access-control-allow-credentials'], 'true');
}
// Cross origin requests from untrusted origin.
for (const config of [anonConfig, chimpyConfig]) {
config.headers.Origin = "https://evil.com/";
config.headers.Origin = forbiddenOrigin;
for (response of [
await axios.post(url, data, config),
await axios.get(url, config),

@ -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;
});
});
}
});

@ -12,7 +12,7 @@ import {createTestDir, EnvironmentSnapshot, setTmpLogLevel} from 'test/server/te
import {assert} from 'chai';
import * as cookie from 'cookie';
import fetch from 'node-fetch';
import WebSocket from 'ws';
import {GristClientSocket} from 'app/client/components/GristClientSocket';
describe('ManyFetches', function() {
this.timeout(30000);
@ -244,7 +244,7 @@ describe('ManyFetches', function() {
return function createConnectionFunc() {
let clientId: string = '0';
return GristWSConnection.create(null, {
makeWebSocket(url: string): any { return new WebSocket(url, undefined, { headers }); },
makeWebSocket(url: string) { return new GristClientSocket(url, { headers }); },
getTimezone() { return Promise.resolve('UTC'); },
getPageUrl() { return pageUrl; },
getDocWorkerUrl() { return Promise.resolve(docWorkerUrl); },

@ -393,17 +393,9 @@ describe('Telemetry', function() {
sandbox.restore();
});
it('GET /install/prefs returns 200 for non-default users', async function() {
it('GET /install/prefs returns 403 for non-default users', async function() {
const resp = await axios.get(`${homeUrl}/api/install/prefs`, kiwi);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data, {
telemetry: {
telemetryLevel: {
value: 'off',
source: 'preferences',
},
},
});
assert.equal(resp.status, 403);
});
it('GET /install/prefs returns 200 for the default user', async function() {

@ -544,6 +544,11 @@
resolved "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz"
integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==
"@socket.io/component-emitter@~3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
"@sqltools/formatter@^1.2.2":
version "1.2.3"
resolved "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.3.tgz"
@ -626,6 +631,18 @@
resolved "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.2.tgz"
integrity sha1-GMyw/RJoCOSYqCDLgUnAByorkGY=
"@types/cookie@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
"@types/cors@^2.8.12":
version "2.8.17"
resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b"
integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==
dependencies:
"@types/node" "*"
"@types/d3@^3":
version "3.5.44"
resolved "https://registry.npmjs.org/@types/d3/-/d3-3.5.44.tgz"
@ -844,6 +861,13 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4"
integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==
"@types/node@>=10.0.0":
version "20.11.16"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.16.tgz#4411f79411514eb8e2926f036c86c9f0e4ec6708"
integrity sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==
dependencies:
undici-types "~5.26.4"
"@types/node@^14.0.1":
version "14.17.6"
resolved "https://registry.npmjs.org/@types/node/-/node-14.17.6.tgz"
@ -988,10 +1012,10 @@
dependencies:
"@types/node" "*"
"@types/ws@^6":
version "6.0.4"
resolved "https://registry.npmjs.org/@types/ws/-/ws-6.0.4.tgz"
integrity sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==
"@types/ws@^8":
version "8.5.10"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787"
integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==
dependencies:
"@types/node" "*"
@ -1253,7 +1277,7 @@ accept-language-parser@1.5.0:
resolved "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz"
integrity sha1-iHfFQECo3LWeCgfZwf3kIpgzR5E=
accepts@~1.3.8:
accepts@~1.3.4, accepts@~1.3.8:
version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
@ -1667,6 +1691,11 @@ base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
base64id@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
basic-auth@~2.0.0:
version "2.0.1"
resolved "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz"
@ -2573,11 +2602,24 @@ cookie@0.5.0:
resolved "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz"
integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==
cookie@~0.4.1:
version "0.4.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
core-util-is@~1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cors@~2.8.5:
version "2.8.5"
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
dependencies:
object-assign "^4"
vary "^1"
crc-32@^1.2.0:
version "1.2.0"
resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz"
@ -2737,7 +2779,7 @@ debug@4:
dependencies:
ms "2.1.2"
debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4:
debug@4.3.4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@~4.3.1:
version "4.3.4"
resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@ -3072,6 +3114,38 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1:
dependencies:
once "^1.4.0"
engine.io-client@^6.5.3:
version "6.5.3"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.3.tgz#4cf6fa24845029b238f83c628916d9149c399bc5"
integrity sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"
engine.io-parser "~5.2.1"
ws "~8.11.0"
xmlhttprequest-ssl "~2.0.0"
engine.io-parser@~5.2.1:
version "5.2.2"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49"
integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==
engine.io@^6.5.4:
version "6.5.4"
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.4.tgz#6822debf324e781add2254e912f8568508850cdc"
integrity sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==
dependencies:
"@types/cookie" "^0.4.1"
"@types/cors" "^2.8.12"
"@types/node" ">=10.0.0"
accepts "~1.3.4"
base64id "2.0.0"
cookie "~0.4.1"
cors "~2.8.5"
debug "~4.3.1"
engine.io-parser "~5.2.1"
ws "~8.11.0"
enhanced-resolve@^5.9.3:
version "5.9.3"
resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz"
@ -5894,7 +5968,7 @@ nwsapi@^2.2.7:
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30"
integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==
object-assign@^4.0.1, object-assign@^4.1.1:
object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
@ -7863,6 +7937,11 @@ underscore@1.12.1, underscore@>=1.8.3, underscore@^1.8.0:
resolved "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz"
integrity sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
unique-filename@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"
@ -8053,7 +8132,7 @@ value-or-function@^3.0.0:
resolved "https://registry.yarnpkg.com/value-or-function/-/value-or-function-3.0.0.tgz#1c243a50b595c1be54a754bfece8563b9ff8d813"
integrity sha512-jdBB2FrWvQC/pnPtIqcLsMaQgjhdb6B7tk1MMyTKapox+tQZbdRP4uLxu/JY0t7fbfDCUMnuelzEYv5GsxHhdg==
vary@~1.1.2:
vary@^1, vary@~1.1.2:
version "1.1.2"
resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz"
integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
@ -8369,6 +8448,11 @@ ws@^8.14.2:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.15.0.tgz#db080a279260c5f532fc668d461b8346efdfcf86"
integrity sha512-H/Z3H55mrcrgjFwI+5jKavgXvwQLtfPCUEp6pi35VhoB0pfcHnSoyuTzkBEZpzq49g1193CUEwIvmsjcotenYw==
ws@~8.11.0:
version "8.11.0"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==
xdg-basedir@^4.0.0:
version "4.0.0"
resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz"
@ -8438,6 +8522,11 @@ xmldom@^0.1.0, xmldom@~0.1.15:
resolved "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz"
integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
xmlhttprequest-ssl@~2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67"
integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==
xpath.js@>=0.0.3:
version "1.1.0"
resolved "https://registry.npmjs.org/xpath.js/-/xpath.js-1.1.0.tgz"

Loading…
Cancel
Save