(core) updates from grist-core

pull/1004/head
Paul Fitzpatrick 4 months ago
commit 06acc47cdb

@ -1,160 +0,0 @@
import { AppModel } from 'app/client/models/AppModel';
import { AdminChecks, ProbeDetails } from 'app/client/models/AdminChecks';
import { createAppPage } from 'app/client/ui/createAppPage';
import { pagePanels } from 'app/client/ui/PagePanels';
import { BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
import { getGristConfig } from 'app/common/urlUtils';
import { Disposable, dom, Observable, styled, UseCBOwner } from 'grainjs';
const cssBody = styled('div', `
padding: 20px;
overflow: auto;
`);
const cssHeader = styled('div', `
padding: 20px;
`);
const cssResult = styled('div', `
max-width: 500px;
`);
/**
*
* A "boot" page for inspecting the state of the Grist installation.
*
* TODO: deferring using any localization machinery so as not
* to have to worry about its failure modes yet, but it should be
* fine as long as assets served locally are used.
*
*/
export class Boot extends Disposable {
private _checks: AdminChecks;
constructor(_appModel: AppModel) {
super();
// Setting title in constructor seems to be how we are doing this,
// based on other similar pages.
document.title = 'Booting Grist';
this._checks = new AdminChecks(this);
}
/**
* Set up the page. Uses the generic Grist layout with an empty
* side panel, just for convenience. Could be made a lot prettier.
*/
public buildDom() {
this._checks.fetchAvailableChecks().catch(e => reportError(e));
const config = getGristConfig();
const errMessage = config.errMessage;
const rootNode = dom('div',
dom.domComputed(
use => {
return pagePanels({
leftPanel: {
panelWidth: Observable.create(this, 240),
panelOpen: Observable.create(this, false),
hideOpener: true,
header: null,
content: null,
},
headerMain: cssHeader(dom('h1', 'Grist Boot')),
contentMain: this.buildBody(use, {errMessage}),
});
}
),
);
return rootNode;
}
/**
* The body of the page is very simple right now, basically a
* placeholder. Make a section for each probe, and kick them off in
* parallel, showing results as they come in.
*/
public buildBody(use: UseCBOwner, options: {errMessage?: string}) {
if (options.errMessage) {
return cssBody(cssResult(this.buildError()));
}
return cssBody([
...use(this._checks.probes).map(probe => {
const req = this._checks.requestCheck(probe);
return cssResult(
this.buildResult(req.probe, use(req.result), req.details));
}),
]);
}
/**
* This is used when there is an attempt to access the boot page
* but something isn't right - either the page isn't enabled, or
* the key in the URL is wrong. Give the user some information about
* how to set things up.
*/
public buildError() {
return dom(
'div',
dom('p',
'A diagnostics page can be made available at:',
dom('blockquote', '/boot/GRIST_BOOT_KEY'),
'GRIST_BOOT_KEY is an environment variable ',
' set before Grist starts. It should only',
' contain characters that are valid in a URL.',
' It should be a secret, since no authentication is needed',
' to visit the diagnostics page.'),
dom('p',
'You are seeing this page because either the key is not set,',
' or it is not in the URL.'),
);
}
/**
* An ugly rendering of information returned by the probe.
*/
public buildResult(info: BootProbeInfo, result: BootProbeResult,
details: ProbeDetails|undefined) {
const out: (HTMLElement|string|null)[] = [];
out.push(dom('h2', info.name));
if (details) {
out.push(dom('p', '> ', details.info));
}
if (result.verdict) {
out.push(dom('pre', result.verdict));
}
if (result.success !== undefined) {
out.push(result.success ? '✅' : '❌');
}
if (result.done === true) {
out.push(dom('p', 'no fault detected'));
}
if (result.details) {
for (const [key, val] of Object.entries(result.details)) {
out.push(dom(
'div',
cssLabel(key),
dom('input', dom.prop('value', JSON.stringify(val)))));
}
}
return out;
}
}
/**
* Create a stripped down page to show boot information.
* Make sure the API isn't used since it may well be unreachable
* due to a misconfiguration, especially in multi-server setups.
*/
createAppPage(appModel => {
return dom.create(Boot, appModel);
}, {
useApi: false,
});
export const cssLabel = styled('div', `
display: inline-block;
min-width: 100px;
text-align: right;
padding-right: 5px;
`);

@ -1,5 +1,6 @@
import { reportError } from 'app/client/models/errors';
import { BootProbeIds, BootProbeInfo, BootProbeResult } from 'app/common/BootProbe';
import { removeTrailingSlash } from 'app/common/gutil';
import { InstallAPI } from 'app/common/InstallAPI';
import { getGristConfig } from 'app/common/urlUtils';
import { Disposable, Observable, UseCBOwner } from 'grainjs';
@ -19,7 +20,7 @@ export class AdminChecks {
// Keep track of probe results we have received, by probe ID.
private _results: Map<string, Observable<BootProbeResult>>;
constructor(private _parent: Disposable) {
constructor(private _parent: Disposable, private _installAPI: InstallAPI) {
this.probes = Observable.create(_parent, []);
this._results = new Map();
this._requests = new Map();
@ -32,18 +33,16 @@ export class AdminChecks {
const config = getGristConfig();
const errMessage = config.errMessage;
if (!errMessage) {
// Probe tool URLs are relative to the current URL. Don't trust configuration,
// because it may be buggy if the user is here looking at the boot page
// to figure out some problem.
//
// We have been careful to make URLs available with appropriate
// middleware relative to both of the admin panel and the boot page.
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname += '/probe';
const resp = await fetch(url.href);
const _probes = await resp.json();
this.probes.set(_probes.probes);
const _probes = await this._installAPI.getChecks().catch(reportError);
if (!this._parent.isDisposed()) {
// Currently, probes are forbidden if not admin.
// TODO: May want to relax this to allow some probes that help
// diagnose some initial auth problems.
this.probes.set(_probes ? _probes.probes : []);
}
return _probes;
}
return [];
}
/**
@ -54,12 +53,12 @@ export class AdminChecks {
const {id} = probe;
let result = this._results.get(id);
if (!result) {
result = Observable.create(this._parent, {});
result = Observable.create(this._parent, {status: 'none'});
this._results.set(id, result);
}
let request = this._requests.get(id);
if (!request) {
request = new AdminCheckRunner(id, this._results, this._parent);
request = new AdminCheckRunner(this._installAPI, id, this._results, this._parent);
this._requests.set(id, request);
}
request.start();
@ -93,15 +92,15 @@ export interface AdminCheckRequest {
* Manage a single check.
*/
export class AdminCheckRunner {
constructor(public id: string, public results: Map<string, Observable<BootProbeResult>>,
constructor(private _installAPI: InstallAPI,
public id: string,
public results: Map<string, Observable<BootProbeResult>>,
public parent: Disposable) {
const url = new URL(removeTrailingSlash(document.location.href));
url.pathname = url.pathname + '/probe/' + id;
fetch(url.href).then(async resp => {
const _probes: BootProbeResult = await resp.json();
this._installAPI.runCheck(id).then(result => {
if (parent.isDisposed()) { return; }
const ob = results.get(id);
if (ob) {
ob.set(_probes);
ob.set(result);
}
}).catch(e => console.error(e));
}
@ -109,7 +108,7 @@ export class AdminCheckRunner {
public start() {
let result = this.results.get(this.id);
if (!result) {
result = Observable.create(this.parent, {});
result = Observable.create(this.parent, {status: 'none'});
this.results.set(this.id, result);
}
}
@ -120,7 +119,7 @@ export class AdminCheckRunner {
* but it can be useful to show extra details and tips in the
* client.
*/
const probeDetails: Record<string, ProbeDetails> = {
export const probeDetails: Record<string, ProbeDetails> = {
'boot-page': {
info: `
This boot page should not be too easy to access. Either turn
@ -144,6 +143,17 @@ is set.
`,
},
'sandboxing': {
info: `
Grist allows for very powerful formulas, using Python.
We recommend setting the environment variable
GRIST_SANDBOX_FLAVOR to gvisor if your hardware
supports it (most will), to run formulas in each document
within a sandbox isolated from other documents and isolated
from the network.
`
},
'system-user': {
info: `
It is good practice not to run Grist as the root user.
@ -153,6 +163,15 @@ It is good practice not to run Grist as the root user.
'reachable': {
info: `
The main page of Grist should be available.
`
},
'websockets': {
// TODO: add a link to https://support.getgrist.com/self-managed/#how-do-i-run-grist-on-a-server
info: `
Websocket connections need HTTP 1.1 and the ability to pass a few
extra headers in order to work. Sometimes a reverse proxy can
interfere with these requirements.
`
},
};

@ -531,10 +531,52 @@ export function getOrgNameOrGuest(org: Organization|null, user: FullUser|null) {
return getOrgName(org);
}
export function getHomeUrl(): string {
/**
* If we don't know what the home URL is, the top level of the site
* we are on may work. This should always work for single-server installs
* that don't encode organization information in domains. Even for other
* cases, this should be a good enough home URL for many purposes, it
* just may still have some organization information encoded in it from
* the domain that could influence results that might be supposed to be
* organization-neutral.
*/
export function getFallbackHomeUrl(): string {
const {host, protocol} = window.location;
return `${protocol}//${host}`;
}
/**
* Get the official home URL sent to us from the back end.
*/
export function getConfiguredHomeUrl(): string {
const gristConfig: any = (window as any).gristConfig;
return (gristConfig && gristConfig.homeUrl) || `${protocol}//${host}`;
return (gristConfig && gristConfig.homeUrl) || getFallbackHomeUrl();
}
/**
* Get the home URL, using fallback if on admin page rather
* than trusting back end configuration.
*/
export function getPreferredHomeUrl(): string|undefined {
const gristUrl = urlState().state.get();
if (gristUrl.adminPanel) {
// On the admin panel, we should not trust configuration much,
// since we want the user to be able to access it to diagnose
// problems with configuration. So we access the API via the
// site we happen to be on rather than anything configured on
// the back end. Couldn't we just always do this? Maybe!
// It could require adjustments for calls that are meant
// to be site-neutral if the domain has an org encoded in it.
// But that's a small price to pay. Grist Labs uses a setup
// where api calls go to a dedicated domain distinct from all
// other sites, but there's no particular advantage to it.
return getFallbackHomeUrl();
}
return getConfiguredHomeUrl();
}
export function getHomeUrl(): string {
return getPreferredHomeUrl() || getConfiguredHomeUrl();
}
export function newUserAPIImpl(): UserAPIImpl {

@ -2,8 +2,8 @@ import {buildHomeBanners} from 'app/client/components/Banners';
import {makeT} from 'app/client/lib/localization';
import {localStorageJsonObs} from 'app/client/lib/localStorageObs';
import {getTimeFromNow} from 'app/client/lib/timeUtils';
import {AdminChecks, probeDetails, ProbeDetails} from 'app/client/models/AdminChecks';
import {AppModel, getHomeUrl, reportError} from 'app/client/models/AppModel';
import {AdminChecks} from 'app/client/models/AdminChecks';
import {urlState} from 'app/client/models/gristUrlState';
import {AppHeader} from 'app/client/ui/AppHeader';
import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon';
@ -15,7 +15,7 @@ import {basicButton} from 'app/client/ui2018/buttons';
import {toggle} from 'app/client/ui2018/checkbox';
import {mediaSmall, testId, theme, vars} from 'app/client/ui2018/cssVars';
import {cssLink, makeLinks} from 'app/client/ui2018/links';
import {SandboxingBootProbeDetails} from 'app/common/BootProbe';
import {BootProbeInfo, BootProbeResult, SandboxingBootProbeDetails} from 'app/common/BootProbe';
import {commonUrls, getPageTitleSuffix} from 'app/common/gristUrls';
import {InstallAPI, InstallAPIImpl, LatestVersion} from 'app/common/InstallAPI';
import {naturalCompare} from 'app/common/SortFunc';
@ -41,7 +41,7 @@ export class AdminPanel extends Disposable {
constructor(private _appModel: AppModel) {
super();
document.title = getAdminPanelName() + getPageTitleSuffix(getGristConfig());
this._checks = new AdminChecks(this);
this._checks = new AdminChecks(this, this._installAPI);
}
public buildDom() {
@ -78,9 +78,42 @@ export class AdminPanel extends Disposable {
}
private _buildMainContent(owner: MultiHolder) {
// If probes are available, show the panel as normal.
// Otherwise say it is unavailable, and describe a fallback
// mechanism for access.
return cssPageContainer(
dom.cls('clipboard'),
{tabIndex: "-1"},
dom.maybe(this._checks.probes, probes => {
return probes.length > 0
? this._buildMainContentForAdmin(owner)
: this._buildMainContentForOthers(owner);
}),
testId('admin-panel'),
);
}
/**
* Show something helpful to those without access to the panel,
* which could include a legit adminstrator if auth is misconfigured.
*/
private _buildMainContentForOthers(owner: MultiHolder) {
const exampleKey = 'example-' + window.crypto.randomUUID();
return dom.create(AdminSection, t('Administrator Panel Unavailable'), [
dom('p', t(`You do not have access to the administrator panel.
Please log in as an administrator.`)),
dom(
'p',
t(`Or, as a fallback, you can set: {{bootKey}} in the environment and visit: {{url}}`, {
bootKey: dom('pre', `GRIST_BOOT_KEY=${exampleKey}`),
url: dom('pre', `/admin?boot-key=${exampleKey}`)
}),
),
]);
}
private _buildMainContentForAdmin(owner: MultiHolder) {
return [
dom.create(AdminSection, t('Support Grist'), [
dom.create(AdminSectionItem, {
id: 'telemetry',
@ -113,7 +146,6 @@ export class AdminPanel extends Disposable {
expandedContent: this._buildAuthenticationNotice(owner),
})
]),
dom.create(AdminSection, t('Version'), [
dom.create(AdminSectionItem, {
id: 'version',
@ -123,8 +155,23 @@ export class AdminPanel extends Disposable {
}),
this._buildUpdates(owner),
]),
testId('admin-panel'),
);
dom.create(AdminSection, t('Self Checks'), [
this._buildProbeItems(owner, {
showRedundant: false,
showNovel: true,
}),
dom.create(AdminSectionItem, {
id: 'probe-other',
name: 'more...',
description: '',
value: '',
expandedContent: this._buildProbeItems(owner, {
showRedundant: true,
showNovel: false,
}),
}),
]),
];
}
private _buildSandboxingDisplay(owner: IDisposableOwner) {
@ -132,7 +179,7 @@ export class AdminPanel extends Disposable {
use => {
const req = this._checks.requestCheckById(use, 'sandboxing');
const result = req ? use(req.result) : undefined;
const success = result?.success;
const success = result?.status === 'success';
const details = result?.details as SandboxingBootProbeDetails|undefined;
if (!details) {
return cssValueLabel(t('unknown'));
@ -150,11 +197,9 @@ export class AdminPanel extends Disposable {
private _buildSandboxingNotice() {
return [
t('Grist allows for very powerful formulas, using Python. \
We recommend setting the environment variable GRIST_SANDBOX_FLAVOR to gvisor \
if your hardware supports it (most will), \
to run formulas in each document within a sandbox \
isolated from other documents and isolated from the network.'),
// Use AdminChecks text for sandboxing, in order not to
// duplicate.
probeDetails['sandboxing'].info,
dom(
'div',
{style: 'margin-top: 8px'},
@ -172,7 +217,8 @@ isolated from other documents and isolated from the network.'),
return cssValueLabel(cssErrorText('unavailable'));
}
const { success, details } = result;
const { status, details } = result;
const success = status === 'success';
const loginSystemId = details?.loginSystemId;
if (!success || !loginSystemId) {
@ -412,7 +458,111 @@ isolated from other documents and isolated from the network.'),
)
});
}
/**
* Show the results of various checks. Of the checks, some are considered
* "redundant" (already covered elsewhere in the Admin Panel) and the
* remainder are "novel".
*/
private _buildProbeItems(owner: MultiHolder, options: {
showRedundant: boolean,
showNovel: boolean,
}) {
return dom.domComputed(
use => [
...use(this._checks.probes).map(probe => {
const isRedundant = probe.id === 'sandboxing';
const show = isRedundant ? options.showRedundant : options.showNovel;
if (!show) { return null; }
const req = this._checks.requestCheck(probe);
return this._buildProbeItem(owner, req.probe, use(req.result), req.details);
}),
]
);
}
/**
* Show the result of an individual check.
*/
private _buildProbeItem(owner: MultiHolder,
info: BootProbeInfo,
result: BootProbeResult,
details: ProbeDetails|undefined) {
const status = this._encodeSuccess(result);
return dom.create(AdminSectionItem, {
id: `probe-${info.id}`,
name: info.id,
description: info.name,
value: cssStatus(status),
expandedContent: [
cssCheckHeader(
t('Results'),
{ style: 'margin-top: 0px; padding-top: 0px;' },
),
result.verdict ? dom('pre', result.verdict) : null,
(result.status === 'none') ? null :
dom('p',
(result.status === 'success') ? t('Check succeeded.') : t('Check failed.')),
(result.status !== 'none') ? null :
dom('p', t('No fault detected.')),
(details?.info === undefined) ? null : [
cssCheckHeader(t('Notes')),
details.info,
],
(result.details === undefined) ? null : [
cssCheckHeader(t('Details')),
...Object.entries(result.details).map(([key, val]) => {
return dom(
'div',
cssLabel(key),
dom('input', dom.prop(
'value',
typeof val === 'string' ? val : JSON.stringify(val))));
}),
],
],
});
}
/**
* Give an icon summarizing success or failure. Factor in the
* severity of the result for failures. This is crude, the
* visualization of the results can be elaborated in future.
*/
private _encodeSuccess(result: BootProbeResult) {
switch (result.status) {
case 'success':
return '✅';
case 'fault':
return '❌';
case 'warning':
return '❗';
case 'hmm':
return '?';
case 'none':
return '―';
default:
// should not arrive here
return '??';
}
}
}
// Ugh I'm not a front end person. h5 small-caps, sure why not.
// Hopefully someone with taste will edit someday!
const cssCheckHeader = styled('h5', `
margin-bottom: 5px;
font-variant: small-caps;
`);
const cssStatus = styled('div', `
display: inline-block;
text-align: center;
width: 40px;
padding: 5px;
`);
const cssPageContainer = styled('div', `
overflow: auto;
@ -491,3 +641,10 @@ export const cssDangerText = styled('div', `
const cssHappyText = styled('span', `
color: ${theme.controlFg};
`);
export const cssLabel = styled('div', `
display: inline-block;
min-width: 100px;
text-align: right;
padding-right: 5px;
`);

@ -61,6 +61,18 @@ export class BaseAPI {
'X-Requested-With': 'XMLHttpRequest',
...options.headers
};
// If we are in the client, and have a boot key query parameter,
// pass it on as a header to make it available for authentication.
// This is a fallback mechanism if auth is broken to access the
// admin panel.
// TODO: should this be more selective?
if (typeof window !== 'undefined' && window.location &&
window.location.pathname.endsWith('/admin')) {
const bootKey = new URLSearchParams(window.location.search).get('boot-key');
if (bootKey) {
this._headers['X-Boot-Key'] = bootKey;
}
}
this._extraParameters = options.extraParameters;
}

@ -7,14 +7,18 @@ export type BootProbeIds =
'host-header' |
'sandboxing' |
'system-user' |
'authentication'
'authentication' |
'websockets'
;
export interface BootProbeResult {
verdict?: string;
success?: boolean;
done?: boolean;
severity?: 'fault' | 'warning' | 'hmm';
// Result of check.
// "success" is a positive outcome.
// "none" means no fault detected (but that the test is not exhaustive
// enough to claim "success").
// "fault" is a bad error, "warning" a ... warning, "hmm" almost a debug message.
status: 'success' | 'fault' | 'warning' | 'hmm' | 'none';
details?: Record<string, any>;
}

@ -1,4 +1,5 @@
import {BaseAPI, IOptions} from 'app/common/BaseAPI';
import {BootProbeInfo, BootProbeResult} from 'app/common/BootProbe';
import {InstallPrefs} from 'app/common/Install';
import {TelemetryLevel} from 'app/common/Telemetry';
import {addCurrentOrgToPath} from 'app/common/urlUtils';
@ -56,6 +57,8 @@ export interface InstallAPI {
* Returns information about latest version of Grist
*/
checkUpdates(): Promise<LatestVersion>;
getChecks(): Promise<{probes: BootProbeInfo[]}>;
runCheck(id: string): Promise<BootProbeResult>;
}
export class InstallAPIImpl extends BaseAPI implements InstallAPI {
@ -78,6 +81,14 @@ export class InstallAPIImpl extends BaseAPI implements InstallAPI {
return this.requestJson(`${this._url}/api/install/updates`, {method: 'GET'});
}
public getChecks(): Promise<{probes: BootProbeInfo[]}> {
return this.requestJson(`${this._url}/api/probes`, {method: 'GET'});
}
public runCheck(id: string): Promise<BootProbeResult> {
return this.requestJson(`${this._url}/api/probes/${id}`, {method: 'GET'});
}
private get _url(): string {
return addCurrentOrgToPath(this._homeUrl);
}

@ -193,6 +193,24 @@ export async function addRequestUser(
}
}
// Check if we have a boot key. This is a fallback mechanism for an
// administrator to authenticate themselves by demonstrating access
// to the environment.
if (!authDone && mreq.headers && mreq.headers['x-boot-key']) {
const reqBootKey = String(mreq.headers['x-boot-key']);
const bootKey = options.gristServer.getBootKey();
if (!bootKey || bootKey !== reqBootKey) {
return res.status(401).send('Bad request: invalid Boot key');
}
const userId = dbManager.getSupportUserId();
const user = await dbManager.getUser(userId);
mreq.user = user;
mreq.userId = userId;
mreq.users = [dbManager.makeFullUser(user!)];
mreq.userIsAuthorized = true;
authDone = true;
}
// Special permission header for internal housekeeping tasks
if (!authDone && mreq.headers && mreq.headers.permit) {
const permitKey = String(mreq.headers.permit);

@ -4,6 +4,7 @@ import { removeTrailingSlash } from 'app/common/gutil';
import { expressWrap, jsonErrorHandler } from 'app/server/lib/expressWrap';
import { GristServer } from 'app/server/lib/GristServer';
import * as express from 'express';
import WS from 'ws';
import fetch from 'node-fetch';
/**
@ -25,7 +26,7 @@ export class BootProbes {
public addEndpoints() {
// Return a list of available probes.
this._app.use(`${this._base}/probe$`,
this._app.use(`${this._base}/probes$`,
...this._middleware,
expressWrap(async (_, res) => {
res.json({
@ -36,7 +37,7 @@ export class BootProbes {
}));
// Return result of running an individual probe.
this._app.use(`${this._base}/probe/:probeId`,
this._app.use(`${this._base}/probes/:probeId`,
...this._middleware,
expressWrap(async (req, res) => {
const probe = this._probeById.get(req.params.probeId);
@ -48,7 +49,7 @@ export class BootProbes {
}));
// Fall-back for errors.
this._app.use(`${this._base}/probe`, jsonErrorHandler);
this._app.use(`${this._base}/probes`, jsonErrorHandler);
}
private _addProbes() {
@ -59,6 +60,7 @@ export class BootProbes {
this._probes.push(_hostHeaderProbe);
this._probes.push(_sandboxingProbe);
this._probes.push(_authenticationProbe);
this._probes.push(_webSocketsProbe);
this._probeById = new Map(this._probes.map(p => [p.id, p]));
}
}
@ -76,38 +78,78 @@ export interface Probe {
const _homeUrlReachableProbe: Probe = {
id: 'reachable',
name: 'Grist is reachable',
name: 'Is home page available at expected URL',
apply: async (server, req) => {
const url = server.getHomeInternalUrl();
const details: Record<string, any> = {
url,
};
try {
const resp = await fetch(url);
details.status = resp.status;
if (resp.status !== 200) {
throw new ApiError(await resp.text(), resp.status);
}
return {
success: true,
status: 'success',
details,
};
} catch (e) {
return {
success: false,
details: {
...details,
error: String(e),
},
severity: 'fault',
status: 'fault',
};
}
}
};
const _webSocketsProbe: Probe = {
id: 'websockets',
name: 'Can we open a websocket with the server',
apply: async (server, req) => {
return new Promise((resolve) => {
const url = new URL(server.getHomeUrl(req));
url.protocol = (url.protocol === 'https:') ? 'wss:' : 'ws:';
const ws = new WS.WebSocket(url.href);
const details: Record<string, any> = {
url,
};
ws.on('open', () => {
ws.send('Just nod if you can hear me.');
resolve({
status: 'success',
details,
});
ws.close();
});
ws.on('error', (ev) => {
details.error = ev.message;
resolve({
status: 'fault',
details,
});
ws.close();
});
});
}
};
const _statusCheckProbe: Probe = {
id: 'health-check',
name: 'Built-in Health check',
name: 'Is an internal health check passing',
apply: async (server, req) => {
const baseUrl = server.getHomeInternalUrl();
const url = new URL(baseUrl);
url.pathname = removeTrailingSlash(url.pathname) + '/status';
const details: Record<string, any> = {
url: url.href,
};
try {
const resp = await fetch(url);
details.status = resp.status;
if (resp.status !== 200) {
throw new Error(`Failed with status ${resp.status}`);
}
@ -116,13 +158,16 @@ const _statusCheckProbe: Probe = {
throw new Error(`Failed, page has unexpected content`);
}
return {
success: true,
status: 'success',
details,
};
} catch (e) {
return {
success: false,
details: {
...details,
error: String(e),
severity: 'fault',
},
status: 'fault',
};
}
},
@ -130,17 +175,21 @@ const _statusCheckProbe: Probe = {
const _userProbe: Probe = {
id: 'system-user',
name: 'System user is sane',
name: 'Is the system user following best practice',
apply: async () => {
const details = {
uid: process.getuid ? process.getuid() : 'unavailable',
};
if (process.getuid && process.getuid() === 0) {
return {
success: false,
details,
verdict: 'User appears to be root (UID 0)',
severity: 'warning',
status: 'warning',
};
} else {
return {
success: true,
status: 'success',
details,
};
}
},
@ -148,15 +197,28 @@ const _userProbe: Probe = {
const _bootProbe: Probe = {
id: 'boot-page',
name: 'Boot page exposure',
name: 'Is the boot page adequately protected',
apply: async (server) => {
if (!server.hasBoot) {
return { success: true };
const bootKey = server.getBootKey() || '';
const hasBoot = Boolean(bootKey);
const details: Record<string, any> = {
bootKeySet: hasBoot,
};
if (!hasBoot) {
return { status: 'success', details };
}
details.bootKeyLength = bootKey.length;
if (bootKey.length < 10) {
return {
verdict: 'Boot key length is shorter than 10.',
details,
status: 'fault',
};
}
const maybeSecureEnough = String(process.env.GRIST_BOOT_KEY).length > 10;
return {
success: maybeSecureEnough,
severity: 'hmm',
verdict: 'Boot key ideally should be removed after installation.',
details,
status: 'warning',
};
},
};
@ -170,35 +232,40 @@ const _bootProbe: Probe = {
*/
const _hostHeaderProbe: Probe = {
id: 'host-header',
name: 'Host header is sane',
name: 'Does the host header look correct',
apply: async (server, req) => {
const host = req.header('host');
const url = new URL(server.getHomeUrl(req));
const details = {
homeUrlHost: url.hostname,
headerHost: host,
};
if (url.hostname === 'localhost') {
return {
done: true,
status: 'none',
details,
};
}
if (String(url.hostname).toLowerCase() !== String(host).toLowerCase()) {
return {
success: false,
severity: 'hmm',
details,
status: 'hmm',
};
}
return {
done: true,
status: 'none',
details,
};
},
};
const _sandboxingProbe: Probe = {
id: 'sandboxing',
name: 'Sandboxing is working',
name: 'Is document sandboxing effective',
apply: async (server, req) => {
const details = server.getSandboxInfo();
return {
success: details?.configured && details?.functional,
status: (details?.configured && details?.functional) ? 'success' : 'fault',
details,
};
},
@ -210,7 +277,7 @@ const _authenticationProbe: Probe = {
apply: async(server, req) => {
const loginSystemId = server.getInfo('loginMiddlewareComment');
return {
success: loginSystemId != undefined,
status: (loginSystemId != undefined) ? 'success' : 'fault',
details: {
loginSystemId,
}

@ -181,7 +181,6 @@ export class FlexServer implements GristServer {
private _getLoginSystem?: () => Promise<GristLoginSystem>;
// Set once ready() is called
private _isReady: boolean = false;
private _probes: BootProbes;
private _updateManager: UpdateManager;
private _sandboxInfo: SandboxInfo;
@ -558,27 +557,17 @@ export class FlexServer implements GristServer {
*/
public addBootPage() {
if (this._check('boot')) { return; }
const bootKey = appSettings.section('boot').flag('key').readString({
envVar: 'GRIST_BOOT_KEY'
});
const base = `/boot/${bootKey}`;
this._probes = new BootProbes(this.app, this, base);
// Respond to /boot, /boot/, /boot/KEY, /boot/KEY/ to give
// a helpful message even if user gets KEY wrong or omits it.
this.app.get('/boot(/(:bootKey/?)?)?$', async (req, res) => {
const goodKey = bootKey && req.params.bootKey === bootKey;
return this._sendAppPage(req, res, {
path: 'boot.html', status: 200, config: goodKey ? {
} : {
errMessage: 'not-the-key',
}, tag: 'boot',
});
// Doing a good redirect is actually pretty subtle and we might
// get it wrong, so just say /boot got moved.
res.send('The /boot/KEY page is now /admin?boot-key=KEY');
});
this._probes.addEndpoints();
}
public hasBoot(): boolean {
return Boolean(this._probes);
public getBootKey(): string|undefined {
return appSettings.section('boot').flag('key').readString({
envVar: 'GRIST_BOOT_KEY'
});
}
public denyRequestsIfNotReady() {
@ -1879,22 +1868,21 @@ export class FlexServer implements GristServer {
const requireInstallAdmin = this.getInstallAdmin().getMiddlewareRequireAdmin();
const adminPageMiddleware = [
this._redirectToHostMiddleware,
// Admin endpoint needs to have very little middleware since each
// piece of middleware creates a new way to fail and leave the admin
// panel inaccessible. Generally the admin panel should report problems
// rather than failing entirely.
this.app.get('/admin', this._userIdMiddleware, expressWrap(async (req, resp) => {
return this.sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
}));
const adminMiddleware = [
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: {}});
}));
const probes = new BootProbes(this.app, this, '/admin', adminPageMiddleware);
const probes = new BootProbes(this.app, this, '/api', adminMiddleware);
probes.addEndpoints();
// Restrict this endpoint to install admins too, for the same reason as the /admin page.
// Restrict this endpoint to install admins
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
const activation = await this._activations.current();
@ -1922,7 +1910,7 @@ export class FlexServer implements GristServer {
// GET api/checkUpdates
// Retrieves the latest version of the client from Grist SAAS endpoint.
this.app.get('/api/install/updates', adminPageMiddleware, expressWrap(async (req, res) => {
this.app.get('/api/install/updates', adminMiddleware, expressWrap(async (req, res) => {
// Prepare data for the telemetry that endpoint might expect.
const installationId = (await this.getActivations().current()).id;
const deploymentType = this.getDeploymentType();

@ -65,7 +65,7 @@ export interface GristServer {
getPlugins(): LocalPlugin[];
servesPlugins(): boolean;
getBundledWidgets(): ICustomWidget[];
hasBoot(): boolean;
getBootKey(): string|undefined;
getSandboxInfo(): SandboxInfo|undefined;
getInfo(key: string): any;
}
@ -158,7 +158,7 @@ export function createDummyGristServer(): GristServer {
servesPlugins() { return false; },
getPlugins() { return []; },
getBundledWidgets() { return []; },
hasBoot() { return false; },
getBootKey() { return undefined; },
getSandboxInfo() { return undefined; },
getInfo(key: string) { return undefined; }
};

@ -158,6 +158,6 @@ export function makeSimpleCreator(opts: {
},
getSqliteVariant: opts.getSqliteVariant,
getSandboxVariants: opts.getSandboxVariants,
createInstallAdmin: opts.createInstallAdmin || (async () => new SimpleInstallAdmin()),
createInstallAdmin: opts.createInstallAdmin || (async (dbManager) => new SimpleInstallAdmin(dbManager)),
};
}

@ -1,4 +1,5 @@
import {ApiError} from 'app/common/ApiError';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import {appSettings} from 'app/server/lib/AppSettings';
import {getUser, RequestWithLogin} from 'app/server/lib/Authorizer';
import {User} from 'app/gen-server/entity/User';
@ -40,13 +41,19 @@ export abstract class InstallAdmin {
}
// 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.
// installation admin. The support user is also accepted.
// Otherwise, there is no admin.
export class SimpleInstallAdmin extends InstallAdmin {
private _installAdminEmail = appSettings.section('access').flag('installAdminEmail').readString({
envVar: 'GRIST_DEFAULT_EMAIL',
});
public constructor(private _dbManager: HomeDBManager) {
super();
}
public override async isAdminUser(user: User): Promise<boolean> {
if (user.id === this._dbManager.getSupportUserId()) { return true; }
return this._installAdminEmail ? (user.loginEmail === this._installAdminEmail) : false;
}
}

@ -0,0 +1,22 @@
import { checkMinIOBucket, checkMinIOExternalStorage,
configureMinIOExternalStorage } from 'app/server/lib/configureMinIOExternalStorage';
import { makeSimpleCreator } from 'app/server/lib/ICreate';
import { Telemetry } from 'app/server/lib/Telemetry';
export const makeCoreCreator = () => makeSimpleCreator({
deploymentType: 'core',
// This can and should be overridden by GRIST_SESSION_SECRET
// (or generated randomly per install, like grist-omnibus does).
sessionSecret: 'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh',
storage: [
{
name: 'minio',
check: () => checkMinIOExternalStorage() !== undefined,
checkBackend: () => checkMinIOBucket(),
create: configureMinIOExternalStorage,
},
],
telemetry: {
create: (dbManager, gristServer) => new Telemetry(dbManager, gristServer),
}
});

@ -0,0 +1,12 @@
import { getForwardAuthLoginSystem } from 'app/server/lib/ForwardAuthLogin';
import { GristLoginSystem } from 'app/server/lib/GristServer';
import { getMinimalLoginSystem } from 'app/server/lib/MinimalLogin';
import { getOIDCLoginSystem } from 'app/server/lib/OIDCConfig';
import { getSamlLoginSystem } from 'app/server/lib/SamlConfig';
export async function getCoreLoginSystem(): Promise<GristLoginSystem> {
return await getSamlLoginSystem() ||
await getOIDCLoginSystem() ||
await getForwardAuthLoginSystem() ||
await getMinimalLoginSystem();
}

@ -14,7 +14,6 @@ module.exports = {
main: "app/client/app",
errorPages: "app/client/errorMain",
apiconsole: "app/client/apiconsole",
boot: "app/client/boot",
billing: "app/client/billingMain",
form: "app/client/formMain",
// Include client test harness if it is present (it won't be in

@ -7,7 +7,8 @@
"repository": "git://github.com/gristlabs/grist-core.git",
"scripts": {
"start": "sandbox/watch.sh",
"start:debug": "NODE_INSPECT=1 sandbox/watch.sh",
"start:debug": "NODE_INSPECT=--inspect sandbox/watch.sh",
"start:debug-brk": "NODE_INSPECT=--inspect-brk sandbox/watch.sh",
"install:python": "buildtools/prepare_python.sh",
"install:python2": "buildtools/prepare_python2.sh",
"install:python3": "buildtools/prepare_python3.sh",

@ -19,6 +19,6 @@ tsc --build -w --preserveWatchOutput $PROJECT &
css_files="app/client/**/*.css"
chokidar "${css_files}" -c "bash -O globstar -c 'cat ${css_files} > static/bundle.css'" &
webpack --config $WEBPACK_CONFIG --mode development --watch &
NODE_PATH=_build:_build/stubs:_build/ext nodemon ${NODE_INSPECT:+--inspect} --delay 1 -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js &
NODE_PATH=_build:_build/stubs:_build/ext nodemon ${NODE_INSPECT} --delay 1 -w _build/app/server -w _build/app/common _build/stubs/app/server/server.js &
wait

@ -1,15 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<!-- INSERT BASE -->
<link rel="icon" type="image/x-icon" href="icons/favicon.png" />
<link rel="stylesheet" href="icons/icons.css">
<!-- INSERT LOCALE -->
<!-- INSERT CONFIG -->
<title>Loading...<!-- INSERT TITLE SUFFIX --></title>
</head>
<body>
<script crossorigin="anonymous" src="boot.bundle.js"></script>
</body>
</html>

@ -128,21 +128,21 @@
"Duplicate rows_other": "Дублирай редовете",
"Duplicate rows_one": "Дублирай реда",
"Filter by this value": "Отсяване по тази стойност",
"Insert column to the right": "Вмъкване на колона вдясно",
"Insert row above": "Вмъкване на ред отгоре",
"Insert row below": "Вмъкнете ред отдолу",
"Reset {{count}} entire columns_other": "Нулирайте {{count}} цели колони",
"Insert column to the right": "Вмъкни колона вдясно",
"Insert row above": "Вмъкни ред отгоре",
"Insert row below": "Вмъкни ред отдолу",
"Reset {{count}} entire columns_other": "Нулирай {{count}} цели колони",
"Copy": "Копирай",
"Comment": "Коментирай",
"Cut": "Изрежи",
"Clear cell": "Изчисти клетка",
"Delete {{count}} rows_one": "Изтрий ред",
"Delete {{count}} rows_other": "Изтрий {{count}} реда",
"Insert column to the left": "Вмъкване на колона отляво",
"Insert row": "Вмъкване на ред",
"Insert column to the left": "Вмъкни колона отляво",
"Insert row": "Вмъкни ред",
"Reset {{count}} columns_one": "Нулиране на колона",
"Reset {{count}} columns_other": "Нулирайте {{count}} колони",
"Reset {{count}} entire columns_one": "Нулирайте цялата колона",
"Reset {{count}} columns_other": "Нулирай {{count}} колони",
"Reset {{count}} entire columns_one": "Нулирай цялата колона",
"Paste": "Постави"
},
"ChartView": {
@ -296,10 +296,10 @@
"API documentation.": "API документация.",
"Copy to clipboard": "Копирай в системния буфер",
"Data Engine": "Двигател обработващ данните",
"Default for DateTime columns": "Стойност по подразбиране за колоните от тип \"дата и час\"",
"Default for DateTime columns": "Начален за колони от тип дати",
"For number and date formats": "За формати на числа и дати",
"Formula times": "Формула пъти",
"ID for API use": "Обозначение за ползване в API",
"ID for API use": "Обозначение в API",
"Manage webhooks": "Управлявай webhooks",
"Python": "Python",
"Python version used": "Ползвана версия на Python",
@ -327,7 +327,7 @@
"python2 (legacy)": "python2 (овехтял)"
},
"DocumentUsage": {
"Attachments Size": "Размер на прикачените файлове",
"Attachments Size": "Размер на файловете",
"Data Size": "Размер на данните",
"For higher limits, ": "За по-високи граници, ",
"Rows": "Редове",

@ -298,7 +298,20 @@
"Webhooks": "Points dancrage Web",
"API Console": "Console de l'API",
"Reload": "Recharger",
"Python": "Python"
"Python": "Python",
"API URL copied to clipboard": "URL de l'API copié",
"API console": "Console de l'API",
"API documentation.": "Documentation de l'API.",
"Coming soon": "Prochainement",
"Copy to clipboard": "Copier dans le presse-papier",
"Currency": "Devise",
"Data Engine": "Moteur de données",
"Default for DateTime columns": "Valeur par défaut pour les colonnes Date et Heure",
"Document ID": "ID du Document",
"For currency columns": "Pour les colonnes de devises",
"Hard reset of data engine": "Réinitialisation du moteur de données",
"Locale": "Langue",
"For number and date formats": "Pour les colonnes de nombre et date"
},
"DocumentUsage": {
"Usage statistics are only available to users with full access to the document data.": "Les statistiques d'utilisation ne sont disponibles qu'aux utilisateurs ayant un accès complet aux données du document.",

@ -38,12 +38,13 @@
},
"DocHistory": {
"Compare to Current": "Comparați cu documentul actual",
"Open Snapshot": "Deschideți Snapshot",
"Open Snapshot": "Deschideți instantaneu",
"Snapshots are unavailable.": "Instantaneele nu sunt disponibile.",
"Snapshots": "Instantanee",
"Activity": "Activitate",
"Compare to Previous": "Comparați cu precedentul",
"Beta": "Beta"
"Beta": "Beta",
"Only owners have access to snapshots for documents with access rules.": "Doar proprietarii au acces la instantanee pentru documentele cu reguli de acces."
},
"AccessRules": {
"Permission to access the document in full when needed": "Permisiune de a accesa documentul în întregime atunci când este necesar",
@ -204,7 +205,7 @@
"Other Sites": "Alte spaţii",
"Pinned Documents": "Documente fixate",
"Featured": "Recomandat",
"Manage Users": "Gestionare Utilizatori",
"Manage Users": "Gestionare utilizatori",
"To restore this document, restore the workspace first.": "Pentru a restaura acest document, mai întâi restaurați spațiul de lucru.",
"You may delete a workspace forever once it has no documents in it.": "Puteți șterge pentru totdeauna un spațiu de lucru odată ce nu are documente în el.",
"Trash": "Gunoi",
@ -289,7 +290,7 @@
},
"HomeLeftPane": {
"All Documents": "Toate documentele",
"Manage Users": "Gestionare Utilizatori",
"Manage Users": "Gestionare utilizatori",
"Tutorial": "Tutorial",
"Delete {{workspace}} and all included documents?": "Ștergeți {{workspace}} și toate documentele incluse?",
"Create Empty Document": "Creați un document gol",
@ -413,7 +414,15 @@
"Insert column to the left": "Inserați coloana la stânga",
"Sorted (#{{count}})_other": "Sortat (#{{count}})",
"Detect duplicates in...": "Detectează duplicatele în...",
"Last updated at": "Ultima actualizare la"
"Last updated at": "Ultima actualizare la",
"Toggle": "Comută",
"Date": "Dată",
"Numeric": "Numeric",
"Text": "Text",
"Integer": "Întreg",
"Choice": "Alege",
"Reference": "Referinţă",
"Any": "Oricare"
},
"RightPanel": {
"WIDGET TITLE": "TITLUL WIDGET-ULUI",
@ -818,7 +827,20 @@
"Engine (experimental {{span}} change at own risk):": "Motor (modificare experimentală de {{span}} pe propriul risc):",
"Document Settings": "Setări document",
"Locale:": "Limba:",
"This document's ID (for API use):": "ID-ul acestui document (pentru utilizarea API):"
"This document's ID (for API use):": "ID-ul acestui document (pentru utilizarea API):",
"API URL copied to clipboard": "API URL a fost copiat in clipboard",
"API documentation.": "Documentatia pentru API.",
"Coming soon": "În curând",
"Copy to clipboard": "Copiază în clipboard",
"Currency": "Monedă",
"Find slow formulas": "Găsiți formule lente",
"For currency columns": "Pentru coloanele valutare",
"Formula times": "Formule de timp",
"Notify other services on doc changes": "Notificați alte servicii cu privire la modificările documentului",
"Python": "Python",
"Python version used": "Versiunea Python folosită",
"Reload": "Reîncarcă",
"For number and date formats": "Pentru formatele de număr și dată"
},
"ColumnTitle": {
"Column ID copied to clipboard": "ID-ul coloanei a fost copiat în clipboard",
@ -949,12 +971,12 @@
"Compare to {{termToUse}}": "Comparați cu {{termToUse}}",
"Download": "Descarcă",
"Replace {{termToUse}}...": "Înlocuiți {{termToUse}}…",
"Duplicate Document": "Duplicare Document",
"Duplicate Document": "Duplicare document",
"Original": "Original",
"Back to Current": "Înapoi la curent",
"Edit without affecting the original": "Editați fără a afecta originalul",
"Work on a Copy": "Lucrați la o copie",
"Manage Users": "Gestionare Utilizatori",
"Manage Users": "Gestionare utilizatori",
"Unsaved": "Nesalvat",
"Save Document": "Salvați documentul",
"Save Copy": "Salvare copie"
@ -1065,12 +1087,12 @@
"CellStyle": {
"HEADER STYLE": "STIL ANTET",
"Header Style": "Stil antet",
"Default header style": "Stilul de antet implicit",
"Mixed style": "Stilul mixt",
"Default header style": "Stil implicit pentru antet",
"Mixed style": "Stil mixt",
"Default cell style": "Stilul de celulă implicit",
"CELL STYLE": "STILUL CELULEI",
"Cell Style": "Stilul Celulei",
"Open row styles": "Deschideți stilurile de rând"
"Open row styles": "Deschide stilurile pentru rând"
},
"ConditionalStyle": {
"Rule must return True or False": "Regula trebuie să returneze Adevărat sau Fals",
@ -1134,7 +1156,8 @@
},
"NTextBox": {
"false": "fals",
"true": "adevărat"
"true": "adevărat",
"Lines": "Linii"
},
"FilterBar": {
"Search Columns": "Căutați Coloane",
@ -1176,7 +1199,7 @@
"Click to insert": "Faceți clic pentru a insera"
},
"pages": {
"Duplicate Page": "Duplicare pagină",
"Duplicate Page": "Duplică pagina",
"You do not have edit access to this document": "Nu aveți acces de editare la acest document",
"Remove": "Elimină",
"Rename": "Redenumiți"
@ -1234,9 +1257,18 @@
"Search all pages": "Căutați în toate paginile"
},
"modals": {
"Save": "Salvați",
"Save": "Salvează",
"Cancel": "Anulare",
"Ok": "OK"
"Ok": "OK",
"Delete": "șterge",
"Are you sure you want to delete these records?": "Ești sigur că vrei să ștergi aceste înregistrări?",
"TIP": "SFAT",
"Don't show tips": "Nu mai arăta sfaturi",
"Are you sure you want to delete this record?": "Ești sigur că vrei să ștergi această înregistrare?",
"Don't ask again.": "Nu mai întreba.",
"Got it": "Am înțeles",
"Don't show again": "Nu mai afișa",
"Don't show again.": "Nu mai afișa."
},
"SiteSwitcher": {
"Switch Sites": "Schimbați spaţiul",

@ -0,0 +1,86 @@
{
"ACUserManager": {
"Enter email address": "Vložiť emailovú adresu",
"Invite new member": "Pozvať nového člena",
"We'll email an invite to {{email}}": "Pozvánku poslať e-mailom na adresu {{email}}"
},
"AccessRules": {
"Add Default Rule": "Pridať predvolené pravidlo",
"Add Column Rule": "Pridať pravidlo stĺpca",
"Add Table Rules": "Pridať pravidlá tabuľky",
"Add User Attributes": "Pridať používateľské atribúty",
"Allow everyone to view Access Rules.": "Umožniť každému zobraziť Prístupové Pravidlá.",
"Attribute name": "Názov atribútu",
"Checking...": "Kontroluje sa…",
"Condition": "Podmienka",
"Default Rules": "Predvolené Pravidlá",
"Delete Table Rules": "Odstrániť Pravidlá Tabuľky",
"Enter Condition": "Zadať Podmienku",
"Everyone": "Každý",
"Everyone Else": "Hocikto Iný",
"Invalid": "Neplatné",
"Attribute to Look Up": "Vyhľadávaný Atribút",
"Lookup Column": "Vyhľadávací stĺpec",
"Lookup Table": "Vyhľadávacia Tabuľka",
"Permission to access the document in full when needed": "Povolenie na úplný prístup k dokumentu v prípade potreby",
"Permission to view Access Rules": "Povolenie na zobrazenie Prístupových Pravidiel",
"Permissions": "Povolenia",
"Remove column {{- colId }} from {{- tableId }} rules": "Odstrániť stĺpec {{- colId }} z pravidiel {{- tableId }}",
"Remove {{- tableId }} rules": "Odstrániť pravidlá {{- tableId }}",
"Reset": "Reset",
"Rules for table ": "Pravidlá pre tabuľku ",
"Save": "Uložiť",
"Special Rules": "Špeciálné pravidlá",
"Type a message...": "Napísať správu…",
"User Attributes": "Používateľské Atribúty",
"View As": "Zobraziť Ako",
"Seed rules": "Seed Pravidlá",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Pri pridávaní pravidiel tabuľky automaticky pridať pravidlo na udelenie úplného prístupu VLASTNÍKOVI.",
"Permission to edit document structure": "Povolenie upravovať štruktúru dokumentu",
"This default should be changed if editors' access is to be limited. ": "Toto predvolené nastavenie by sa malo zmeniť, ak má byť prístup redaktorov obmedzený. ",
"Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Umožnite každému skopírovať celý dokument alebo ho zobraziť celý vo fiddle móde.\n Užitočné pre príklady a šablóny, ale nie pre citlivé údaje.",
"Saved": "Uložené",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Umožniť editorom upravovať štruktúru (napr. upravovať a mazať tabuľky, stĺpce, rozloženia) a písať vzorce, ktoré umožňujú prístup ku všetkým údajom bez ohľadu na obmedzenia čítania.",
"Remove {{- name }} user attribute": "Odstrániť používateľský atribút {{- name }}"
},
"AccountPage": {
"API": "API",
"API Key": "API Kľúč",
"Account settings": "Nastavenia účtu",
"Allow signing in to this account with Google": "Povoliť prihlásenie do tohto účtu pomocou Google",
"Change Password": "Zmeniť Heslo",
"Login Method": "Metóda Prihlásenia",
"Password & Security": "Heslo a Zabezpečenie",
"Save": "Uložiť",
"Theme": "Téma",
"Two-factor authentication": "Dvojvázové overenie",
"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "Dvojfázová autentifikácia je ďalšou vrstvou zabezpečenia vášho účtu Grist, ktorá je navrhnutá tak, aby zaistila, že ste jedinou osobou, ktorá môže pristupovať k vášmu účtu, aj keď niekto pozná vaše heslo.",
"Language": "Jazyk",
"Edit": "Upraviť",
"Email": "E-mail",
"Names only allow letters, numbers and certain special characters": "Názvy povoľujú iba písmená, čísla a určité špeciálne znaky",
"Name": "Meno"
},
"AccountWidget": {
"Access Details": "Prístupové podrobnosti",
"Accounts": "Účty",
"Add Account": "Pridať účet",
"Document Settings": "Nastavenie Dokumentu",
"Manage Team": "Spravovať tím",
"Pricing": "Cenník",
"Profile Settings": "Nastavenie profilu",
"Sign Out": "Odhlásiť sa",
"Sign in": "Prihlásiť sa",
"Switch Accounts": "Prepnúť Účty",
"Toggle Mobile Mode": "Prepnúť Mobilný Režim",
"Activation": "Aktivácia",
"Billing Account": "Fakturačný účet",
"Support Grist": "Podpora Grist",
"Upgrade Plan": "Plán Inovácie",
"Sign In": "Prihlásiť sa",
"Use This Template": "Použiť túto Šablónu"
},
"ViewAsDropdown": {
"View As": "Zobraziť Ako"
}
}

@ -266,7 +266,8 @@
},
"OnBoardingPopups": {
"Finish": "Zaključek",
"Next": "Naslednji"
"Next": "Naslednji",
"Previous": "Prejšenj"
},
"Pages": {
"Delete": "Izbriši",
@ -512,7 +513,19 @@
"For number and date formats": "Za format števila in datuma",
"Formula times": "Časi formule",
"API URL copied to clipboard": "URL API-ja kopiran v odložišče",
"Default for DateTime columns": "Privzeto za stolpce DateTime"
"Default for DateTime columns": "Privzeto za stolpce DateTime",
"ID for API use": "ID za uporabo API-ja",
"Locale": "Lokalizacija",
"Hard reset of data engine": "Ponastavitev podatkovnega mehanizma",
"Manage webhooks": "Upravljanje webhookov",
"Notify other services on doc changes": "Obvesti druge storitve o spremembah dokumenta",
"Python": "Python",
"Python version used": "Uporabljena različica Pythona",
"Reload": "Ponovno naloži",
"Time Zone": "Časovni pas",
"Try API calls from the browser": "Poskusi klice API-ja iz brskalnika",
"python3 (recommended)": "python3 (priporočeno)",
"python2 (legacy)": "python2 (odsvetovano)"
},
"GridOptions": {
"Horizontal Gridlines": "Vodoravne linije",
@ -1350,7 +1363,8 @@
"Got it": "Razumem",
"Don't show again": "Ne pokaži več",
"Are you sure you want to delete these records?": "Ali si prepričan, da želiš izbrisati te zapise?",
"Delete": "Briši"
"Delete": "Briši",
"TIP": "NAMIG"
},
"sendToDrive": {
"Sending file to Google Drive": "Pošiljanje datoteke v Google Drive"
@ -1558,5 +1572,20 @@
"Set dropdown condition": "Nastqavi dropdown pogoj",
"Dropdown Condition": "Pogoj za spustni menu",
"Invalid columns: {{colIds}}": "Neveljavni stolpci: {{colIds}}"
},
"FormRenderer": {
"Search": "Poišči",
"Select...": "Izberi",
"Submit": "Predloži",
"Reset": "Ponastavi"
},
"widgetTypesMap": {
"Calendar": "Koledar",
"Card": "Kartica",
"Card List": "Seznam kartic",
"Chart": "grafikon",
"Custom": "Po meri",
"Form": "Forma",
"Table": "Tabela"
}
}

@ -1,22 +1,13 @@
import { checkMinIOBucket, checkMinIOExternalStorage,
configureMinIOExternalStorage } from 'app/server/lib/configureMinIOExternalStorage';
import { makeSimpleCreator } from 'app/server/lib/ICreate';
import { Telemetry } from 'app/server/lib/Telemetry';
import {ICreate} from "app/server/lib/ICreate";
import {makeCoreCreator} from "app/server/lib/coreCreator";
export const create = makeSimpleCreator({
deploymentType: 'core',
// This can and should be overridden by GRIST_SESSION_SECRET
// (or generated randomly per install, like grist-omnibus does).
sessionSecret: 'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh',
storage: [
{
name: 'minio',
check: () => checkMinIOExternalStorage() !== undefined,
checkBackend: () => checkMinIOBucket(),
create: configureMinIOExternalStorage,
},
],
telemetry: {
create: (dbManager, gristServer) => new Telemetry(dbManager, gristServer),
export const create: ICreate = makeCoreCreator();
/**
* Fetch the ICreate object for grist-core.
* Placeholder to enable eventual refactoring away from a global singleton constant.
* Needs to exist in all repositories before core can be switched!
*/
export function getCreator(): ICreate {
return create;
}
});

@ -1,12 +1,6 @@
import { getForwardAuthLoginSystem } from 'app/server/lib/ForwardAuthLogin';
import { GristLoginSystem } from 'app/server/lib/GristServer';
import { getMinimalLoginSystem } from 'app/server/lib/MinimalLogin';
import { getOIDCLoginSystem } from 'app/server/lib/OIDCConfig';
import { getSamlLoginSystem } from 'app/server/lib/SamlConfig';
import { getCoreLoginSystem } from "app/server/lib/coreLogins";
import { GristLoginSystem } from "app/server/lib/GristServer";
export async function getLoginSystem(): Promise<GristLoginSystem> {
return await getSamlLoginSystem() ||
await getOIDCLoginSystem() ||
await getForwardAuthLoginSystem() ||
await getMinimalLoginSystem();
return getCoreLoginSystem();
}

@ -31,7 +31,7 @@ describe('AdminPanel', function() {
await server.restart(true);
});
it('should not be shown to non-managers', async function() {
it('should show an explanation to non-managers', async function() {
session = await gu.session().user('user2').personalSite.login();
await session.loadDocMenu('/');
@ -42,8 +42,9 @@ describe('AdminPanel', function() {
// 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);
await waitForAdminPanel();
assert.equal(await driver.find('.test-admin-panel').isDisplayed(), true);
assert.match(await driver.find('.test-admin-panel').getText(), /Administrator Panel Unavailable/);
});
it('should be shown to managers', async function() {
@ -192,6 +193,23 @@ describe('AdminPanel', function() {
// useful there yet.
});
it('should show various self checks', async function() {
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
await gu.waitToPass(
async () => {
assert.equal(await driver.find('.test-admin-panel-item-name-probe-reachable').isDisplayed(), true);
assert.match(await driver.find('.test-admin-panel-item-value-probe-reachable').getText(), /✅/);
},
3000,
);
assert.equal(await driver.find('.test-admin-panel-item-name-probe-system-user').isDisplayed(), true);
await gu.waitToPass(
async () => assert.match(await driver.find('.test-admin-panel-item-value-probe-system-user').getText(), /✅/),
3000,
);
});
const upperCheckNow = () => driver.find('.test-admin-panel-updates-upper-check-now');
const lowerCheckNow = () => driver.find('.test-admin-panel-updates-lower-check-now');
const autoCheckToggle = () => driver.find('.test-admin-panel-updates-auto-check');
@ -313,6 +331,33 @@ describe('AdminPanel', function() {
});
assert.isNotEmpty(fakeServer.payload.installationId);
});
it('should survive APP_HOME_URL misconfiguration', async function() {
process.env.APP_HOME_URL = 'http://misconfigured.invalid';
process.env.GRIST_BOOT_KEY = 'zig';
await server.restart(true);
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
});
it('should honor GRIST_BOOT_KEY fallback', async function() {
await gu.removeLogin();
await driver.get(`${server.getHost()}/admin`);
await waitForAdminPanel();
assert.equal(await driver.find('.test-admin-panel').isDisplayed(), true);
assert.match(await driver.find('.test-admin-panel').getText(), /Administrator Panel Unavailable/);
process.env.GRIST_BOOT_KEY = 'zig';
await server.restart(true);
await driver.get(`${server.getHost()}/admin?boot-key=zig`);
await waitForAdminPanel();
assert.equal(await driver.find('.test-admin-panel').isDisplayed(), true);
assert.notMatch(await driver.find('.test-admin-panel').getText(), /Administrator Panel Unavailable/);
await driver.get(`${server.getHost()}/admin?boot-key=zig-wrong`);
await waitForAdminPanel();
assert.equal(await driver.find('.test-admin-panel').isDisplayed(), true);
assert.match(await driver.find('.test-admin-panel').getText(), /Administrator Panel Unavailable/);
});
});
async function assertTelemetryLevel(level: TelemetryLevel) {

@ -3,6 +3,10 @@ import * as gu from 'test/nbrowser/gristUtils';
import {server, setupTestSuite} from 'test/nbrowser/testUtils';
import * as testUtils from 'test/server/testUtils';
/**
* The boot page functionality has been merged with the Admin Panel.
* Check that it behaves as a boot page did now.
*/
describe('Boot', function() {
this.timeout(30000);
setupTestSuite();
@ -12,13 +16,24 @@ describe('Boot', function() {
afterEach(() => gu.checkForErrors());
async function hasPrompt() {
// There is some glitchiness to when the text appears.
await gu.waitToPass(async () => {
assert.include(
await driver.findContentWait('p', /diagnostics page/, 2000).getText(),
'A diagnostics page can be made available');
await driver.findContentWait('pre', /GRIST_BOOT_KEY/, 2000).getText(),
'GRIST_BOOT_KEY=example-');
}, 3000);
}
it('gives prompt about how to enable boot page', async function() {
it('tells user about /admin', async function() {
await driver.get(`${server.getHost()}/boot`);
assert.match(await driver.getPageSource(), /\/admin/);
// Switch to a regular place to that gu.checkForErrors won't panic -
// it needs a Grist page.
await driver.get(`${server.getHost()}`);
});
it('gives prompt about how to enable boot page', async function() {
await driver.get(`${server.getHost()}/admin`);
await hasPrompt();
});
@ -35,18 +50,18 @@ describe('Boot', function() {
});
it('gives prompt when key is missing', async function() {
await driver.get(`${server.getHost()}/boot`);
await driver.get(`${server.getHost()}/admin`);
await hasPrompt();
});
it('gives prompt when key is wrong', async function() {
await driver.get(`${server.getHost()}/boot/bilbo`);
await driver.get(`${server.getHost()}/admin?boot-key=bilbo`);
await hasPrompt();
});
it('gives page when key is right', async function() {
await driver.get(`${server.getHost()}/boot/lala`);
await driver.findContentWait('h2', /Grist is reachable/, 2000);
await driver.get(`${server.getHost()}/admin?boot-key=lala`);
await driver.findContentWait('div', /Is home page available/, 2000);
});
});
});

Loading…
Cancel
Save