mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) updates from grist-core
This commit is contained in:
commit
76a43129f1
18
.github/workflows/self-hosted.yml
vendored
Normal file
18
.github/workflows/self-hosted.yml
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
name: Add self-hosting issues to the self-hosting project
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- labeled
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
add-to-project:
|
||||||
|
name: Add issue to project
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/add-to-project@v1.0.1
|
||||||
|
with:
|
||||||
|
project-url: https://github.com/orgs/gristlabs/projects/2
|
||||||
|
github-token: ${{ secrets.SELF_HOSTED_PROJECT }}
|
||||||
|
labeled: self-hosting
|
@ -235,10 +235,11 @@ Grist can be configured in many ways. Here are the main environment variables it
|
|||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| ALLOWED_WEBHOOK_DOMAINS | comma-separated list of permitted domains to use in webhooks (e.g. webhook.site,zapier.com). You can set this to `*` to allow all domains, but if doing so, we recommend using a carefully locked-down proxy (see `GRIST_HTTPS_PROXY`) if you do not entirely trust users. Otherwise services on your internal network may become vulnerable to manipulation. |
|
| ALLOWED_WEBHOOK_DOMAINS | comma-separated list of permitted domains to use in webhooks (e.g. webhook.site,zapier.com). You can set this to `*` to allow all domains, but if doing so, we recommend using a carefully locked-down proxy (see `GRIST_HTTPS_PROXY`) if you do not entirely trust users. Otherwise services on your internal network may become vulnerable to manipulation. |
|
||||||
| APP_DOC_URL | doc worker url, set when starting an individual doc worker (other servers will find doc worker urls via redis) |
|
| APP_DOC_URL | doc worker url, set when starting an individual doc worker (other servers will find doc worker urls via redis) |
|
||||||
| APP_DOC_INTERNAL_URL | like `APP_DOC_URL` but used by the home server to reach the server using an internal domain name resolution (like in a docker environment). Defaults to `APP_DOC_URL` |
|
| APP_DOC_INTERNAL_URL | like `APP_DOC_URL` but used by the home server to reach the server using an internal domain name resolution (like in a docker environment). It only makes sense to define this value in the doc worker. Defaults to `APP_DOC_URL`. |
|
||||||
| APP_HOME_URL | url prefix for home api (home and doc servers need this) |
|
| APP_HOME_URL | url prefix for home api (home and doc servers need this) |
|
||||||
|
| APP_HOME_INTERNAL_URL | like `APP_HOME_URL` but used by the home and the doc servers to reach any home workers using an internal domain name resolution (like in a docker environment). Defaults to `APP_HOME_URL` |
|
||||||
| APP_STATIC_URL | url prefix for static resources |
|
| APP_STATIC_URL | url prefix for static resources |
|
||||||
| APP_STATIC_INCLUDE_CUSTOM_CSS | set to "true" to include custom.css (from APP_STATIC_URL) in static pages |
|
| APP_STATIC_INCLUDE_CUSTOM_CSS | set to "true" to include custom.css (from APP_STATIC_URL) in static pages |
|
||||||
| APP_UNTRUSTED_URL | URL at which to serve/expect plugin content. |
|
| APP_UNTRUSTED_URL | URL at which to serve/expect plugin content. |
|
||||||
@ -280,6 +281,7 @@ Grist can be configured in many ways. Here are the main environment variables it
|
|||||||
| GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org |
|
| GRIST_SINGLE_ORG | set to an org "domain" to pin client to that org |
|
||||||
| GRIST_TEMPLATE_ORG | set to an org "domain" to show public docs from that org |
|
| GRIST_TEMPLATE_ORG | set to an org "domain" to show public docs from that org |
|
||||||
| GRIST_HELP_CENTER | set the help center link ref |
|
| GRIST_HELP_CENTER | set the help center link ref |
|
||||||
|
| GRIST_TERMS_OF_SERVICE_URL | if set, adds terms of service link |
|
||||||
| FREE_COACHING_CALL_URL | set the link to the human help (example: email adress or meeting scheduling tool) |
|
| FREE_COACHING_CALL_URL | set the link to the human help (example: email adress or meeting scheduling tool) |
|
||||||
| GRIST_CONTACT_SUPPORT_URL | set the link to contact support on error pages (example: email adress or online form) |
|
| GRIST_CONTACT_SUPPORT_URL | set the link to contact support on error pages (example: email adress or online form) |
|
||||||
| GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default) |
|
| GRIST_SUPPORT_ANON | if set to 'true', show UI for anonymous access (not shown by default) |
|
||||||
|
@ -460,7 +460,9 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
// - Widget type description (if not grid)
|
// - Widget type description (if not grid)
|
||||||
// All concatenated separated by space.
|
// All concatenated separated by space.
|
||||||
this.defaultWidgetTitle = this.autoDispose(ko.pureComputed(() => {
|
this.defaultWidgetTitle = this.autoDispose(ko.pureComputed(() => {
|
||||||
const widgetTypeDesc = this.parentKey() !== 'record' ? `${getWidgetTypes(this.parentKey.peek() as any).label}` : '';
|
const widgetTypeDesc = this.parentKey() !== 'record'
|
||||||
|
? `${getWidgetTypes(this.parentKey.peek() as any).getLabel()}`
|
||||||
|
: '';
|
||||||
const table = this.table();
|
const table = this.table();
|
||||||
return [
|
return [
|
||||||
table.tableNameDef()?.toUpperCase(), // Due to ACL this can be null.
|
table.tableNameDef()?.toUpperCase(), // Due to ACL this can be null.
|
||||||
|
@ -105,6 +105,13 @@ export class AdminPanel extends Disposable {
|
|||||||
value: this._buildSandboxingDisplay(owner),
|
value: this._buildSandboxingDisplay(owner),
|
||||||
expandedContent: this._buildSandboxingNotice(),
|
expandedContent: this._buildSandboxingNotice(),
|
||||||
}),
|
}),
|
||||||
|
dom.create(AdminSectionItem, {
|
||||||
|
id: 'authentication',
|
||||||
|
name: t('Authentication'),
|
||||||
|
description: t('Current authentication method'),
|
||||||
|
value: this._buildAuthenticationDisplay(owner),
|
||||||
|
expandedContent: this._buildAuthenticationNotice(owner),
|
||||||
|
})
|
||||||
]),
|
]),
|
||||||
|
|
||||||
dom.create(AdminSection, t('Version'), [
|
dom.create(AdminSection, t('Version'), [
|
||||||
@ -156,6 +163,37 @@ isolated from other documents and isolated from the network.'),
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _buildAuthenticationDisplay(owner: IDisposableOwner) {
|
||||||
|
return dom.domComputed(
|
||||||
|
use => {
|
||||||
|
const req = this._checks.requestCheckById(use, 'authentication');
|
||||||
|
const result = req ? use(req.result) : undefined;
|
||||||
|
if (!result) {
|
||||||
|
return cssValueLabel(cssErrorText('unavailable'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { success, details } = result;
|
||||||
|
const loginSystemId = details?.loginSystemId;
|
||||||
|
|
||||||
|
if (!success || !loginSystemId) {
|
||||||
|
return cssValueLabel(cssErrorText('auth error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginSystemId === 'no-logins') {
|
||||||
|
return cssValueLabel(cssDangerText('no authentication'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cssValueLabel(cssHappyText(loginSystemId));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildAuthenticationNotice(owner: IDisposableOwner) {
|
||||||
|
return t('Grist allows different types of authentication to be configured, including SAML and OIDC. \
|
||||||
|
We recommend enabling one of these if Grist is accessible over the network or being made available \
|
||||||
|
to multiple people.');
|
||||||
|
}
|
||||||
|
|
||||||
private _buildUpdates(owner: MultiHolder) {
|
private _buildUpdates(owner: MultiHolder) {
|
||||||
// We can be in those states:
|
// We can be in those states:
|
||||||
enum State {
|
enum State {
|
||||||
@ -446,6 +484,10 @@ const cssErrorText = styled('span', `
|
|||||||
color: ${theme.errorText};
|
color: ${theme.errorText};
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
export const cssDangerText = styled('div', `
|
||||||
|
color: ${theme.dangerText};
|
||||||
|
`);
|
||||||
|
|
||||||
const cssHappyText = styled('span', `
|
const cssHappyText = styled('span', `
|
||||||
color: ${theme.controlFg};
|
color: ${theme.controlFg};
|
||||||
`);
|
`);
|
||||||
|
@ -124,6 +124,7 @@ const cssItemName = styled('div', `
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-right: 14px;
|
||||||
font-size: ${vars.largeFontSize};
|
font-size: ${vars.largeFontSize};
|
||||||
padding-left: 24px;
|
padding-left: 24px;
|
||||||
&-prefixed {
|
&-prefixed {
|
||||||
|
@ -130,7 +130,10 @@ export function columnFilterMenu(owner: IDisposableOwner, opts: IFilterMenuOptio
|
|||||||
dom.onDispose(() => cancel ? doCancel() : doSave()),
|
dom.onDispose(() => cancel ? doCancel() : doSave()),
|
||||||
dom.onKeyDown({
|
dom.onKeyDown({
|
||||||
Enter: () => onClose(),
|
Enter: () => onClose(),
|
||||||
Escape: () => onClose(),
|
Escape: () => {
|
||||||
|
cancel = true;
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Filter by range
|
// Filter by range
|
||||||
|
@ -146,6 +146,14 @@ export function createHomeLeftPane(leftPanelOpen: Observable<boolean>, home: Hom
|
|||||||
) : null
|
) : null
|
||||||
),
|
),
|
||||||
createHelpTools(home.app),
|
createHelpTools(home.app),
|
||||||
|
(commonUrls.termsOfService ?
|
||||||
|
cssPageEntry(
|
||||||
|
cssPageLink(cssPageIcon('Memo'), cssLinkText(t("Terms of service")),
|
||||||
|
{ href: commonUrls.termsOfService, target: '_blank' },
|
||||||
|
testId('dm-tos'),
|
||||||
|
),
|
||||||
|
) : null
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -205,7 +205,7 @@ export function buildPageWidgetPicker(
|
|||||||
// If savePromise throws an error, before or after timeout, we let the error propagate as it
|
// If savePromise throws an error, before or after timeout, we let the error propagate as it
|
||||||
// should be handle by the caller.
|
// should be handle by the caller.
|
||||||
if (await isLongerThan(savePromise, DELAY_BEFORE_SPINNER_MS)) {
|
if (await isLongerThan(savePromise, DELAY_BEFORE_SPINNER_MS)) {
|
||||||
const label = getWidgetTypes(type).label;
|
const label = getWidgetTypes(type).getLabel();
|
||||||
await spinnerModal(t("Building {{- label}} widget", { label }), savePromise);
|
await spinnerModal(t("Building {{- label}} widget", { label }), savePromise);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -317,12 +317,12 @@ export class PageWidgetSelect extends Disposable {
|
|||||||
cssPanel(
|
cssPanel(
|
||||||
header(t("Select Widget")),
|
header(t("Select Widget")),
|
||||||
sectionTypes.map((value) => {
|
sectionTypes.map((value) => {
|
||||||
const {label, icon: iconName} = getWidgetTypes(value);
|
const widgetInfo = getWidgetTypes(value);
|
||||||
const disabled = computed(this._value.table, (use, tid) => this._isTypeDisabled(value, tid));
|
const disabled = computed(this._value.table, (use, tid) => this._isTypeDisabled(value, tid));
|
||||||
return cssEntry(
|
return cssEntry(
|
||||||
dom.autoDispose(disabled),
|
dom.autoDispose(disabled),
|
||||||
cssTypeIcon(iconName),
|
cssTypeIcon(widgetInfo.icon),
|
||||||
label,
|
widgetInfo.getLabel(),
|
||||||
dom.on('click', () => !disabled.get() && this._selectType(value)),
|
dom.on('click', () => !disabled.get() && this._selectType(value)),
|
||||||
cssEntry.cls('-selected', (use) => use(this._value.type) === value),
|
cssEntry.cls('-selected', (use) => use(this._value.type) === value),
|
||||||
cssEntry.cls('-disabled', disabled),
|
cssEntry.cls('-disabled', disabled),
|
||||||
|
@ -38,7 +38,7 @@ import {PredefinedCustomSectionConfig} from "app/client/ui/PredefinedCustomSecti
|
|||||||
import {cssLabel} from 'app/client/ui/RightPanelStyles';
|
import {cssLabel} from 'app/client/ui/RightPanelStyles';
|
||||||
import {linkId, NoLink, selectBy} from 'app/client/ui/selectBy';
|
import {linkId, NoLink, selectBy} from 'app/client/ui/selectBy';
|
||||||
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
|
import {VisibleFieldsConfig} from 'app/client/ui/VisibleFieldsConfig';
|
||||||
import {getTelemetryWidgetTypeFromVS, widgetTypesMap} from "app/client/ui/widgetTypesMap";
|
import {getTelemetryWidgetTypeFromVS, getWidgetTypes} from "app/client/ui/widgetTypesMap";
|
||||||
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
|
||||||
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
|
import {buttonSelect} from 'app/client/ui2018/buttonSelect';
|
||||||
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox';
|
||||||
@ -220,10 +220,10 @@ export class RightPanel extends Disposable {
|
|||||||
|
|
||||||
private _buildStandardHeader() {
|
private _buildStandardHeader() {
|
||||||
return dom.maybe(this._pageWidgetType, (type) => {
|
return dom.maybe(this._pageWidgetType, (type) => {
|
||||||
const widgetInfo = widgetTypesMap.get(type) || {label: 'Table', icon: 'TypeTable'};
|
const widgetInfo = getWidgetTypes(type);
|
||||||
const fieldInfo = getFieldType(type);
|
const fieldInfo = getFieldType(type);
|
||||||
return [
|
return [
|
||||||
cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.label,
|
cssTopBarItem(cssTopBarIcon(widgetInfo.icon), widgetInfo.getLabel(),
|
||||||
cssTopBarItem.cls('-selected', (use) => use(this._topTab) === 'pageWidget'),
|
cssTopBarItem.cls('-selected', (use) => use(this._topTab) === 'pageWidget'),
|
||||||
dom.on('click', () => this._topTab.set("pageWidget")),
|
dom.on('click', () => this._topTab.set("pageWidget")),
|
||||||
testId('right-tab-pagewidget')),
|
testId('right-tab-pagewidget')),
|
||||||
|
@ -3,21 +3,25 @@ import {ViewSectionRec} from "app/client/models/entities/ViewSectionRec";
|
|||||||
import {IPageWidget} from "app/client/ui/PageWidgetPicker";
|
import {IPageWidget} from "app/client/ui/PageWidgetPicker";
|
||||||
import {IconName} from "app/client/ui2018/IconList";
|
import {IconName} from "app/client/ui2018/IconList";
|
||||||
import {IWidgetType} from "app/common/widgetTypes";
|
import {IWidgetType} from "app/common/widgetTypes";
|
||||||
|
import {makeT} from 'app/client/lib/localization';
|
||||||
|
|
||||||
|
const t = makeT('widgetTypesMap');
|
||||||
|
|
||||||
export const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([
|
export const widgetTypesMap = new Map<IWidgetType, IWidgetTypeInfo>([
|
||||||
['record', {label: 'Table', icon: 'TypeTable'}],
|
['record', {name: 'Table', icon: 'TypeTable', getLabel: () => t('Table')}],
|
||||||
['single', {label: 'Card', icon: 'TypeCard'}],
|
['single', {name: 'Card', icon: 'TypeCard', getLabel: () => t('Card')}],
|
||||||
['detail', {label: 'Card List', icon: 'TypeCardList'}],
|
['detail', {name: 'Card List', icon: 'TypeCardList', getLabel: () => t('Card List')}],
|
||||||
['chart', {label: 'Chart', icon: 'TypeChart'}],
|
['chart', {name: 'Chart', icon: 'TypeChart', getLabel: () => t('Chart')}],
|
||||||
['form', {label: 'Form', icon: 'Board'}],
|
['form', {name: 'Form', icon: 'Board', getLabel: () => t('Form')}],
|
||||||
['custom', {label: 'Custom', icon: 'TypeCustom'}],
|
['custom', {name: 'Custom', icon: 'TypeCustom', getLabel: () => t('Custom')}],
|
||||||
['custom.calendar', {label: 'Calendar', icon: 'TypeCalendar'}],
|
['custom.calendar', {name: 'Calendar', icon: 'TypeCalendar', getLabel: () => t('Calendar')}],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Widget type info.
|
// Widget type info.
|
||||||
export interface IWidgetTypeInfo {
|
export interface IWidgetTypeInfo {
|
||||||
label: string;
|
name: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
|
getLabel: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the widget type info for sectionType, or the one for 'record' if sectionType is null.
|
// Returns the widget type info for sectionType, or the one for 'record' if sectionType is null.
|
||||||
@ -46,7 +50,7 @@ export function getTelemetryWidgetTypeFromPageWidget(widget: IPageWidget) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTelemetryWidgetType(type: IWidgetType, options: GetTelemetryWidgetTypeOptions = {}) {
|
function getTelemetryWidgetType(type: IWidgetType, options: GetTelemetryWidgetTypeOptions = {}) {
|
||||||
let telemetryWidgetType: string | undefined = widgetTypesMap.get(type)?.label;
|
let telemetryWidgetType: string | undefined = widgetTypesMap.get(type)?.name;
|
||||||
if (!telemetryWidgetType) { return undefined; }
|
if (!telemetryWidgetType) { return undefined; }
|
||||||
|
|
||||||
if (options.isNewTable) {
|
if (options.isNewTable) {
|
||||||
|
@ -6,7 +6,8 @@ export type BootProbeIds =
|
|||||||
'reachable' |
|
'reachable' |
|
||||||
'host-header' |
|
'host-header' |
|
||||||
'sandboxing' |
|
'sandboxing' |
|
||||||
'system-user'
|
'system-user' |
|
||||||
|
'authentication'
|
||||||
;
|
;
|
||||||
|
|
||||||
export interface BootProbeResult {
|
export interface BootProbeResult {
|
||||||
|
@ -780,11 +780,11 @@ export class UserAPIImpl extends BaseAPI implements UserAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getWorker(key: string): Promise<string> {
|
public async getWorker(key: string): Promise<string> {
|
||||||
const json = await this.requestJson(`${this._url}/api/worker/${key}`, {
|
const json = (await this.requestJson(`${this._url}/api/worker/${key}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
})) as PublicDocWorkerUrlInfo;
|
||||||
return getDocWorkerUrl(this._homeUrl, json);
|
return getPublicDocWorkerUrl(this._homeUrl, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getWorkerAPI(key: string): Promise<DocWorkerAPI> {
|
public async getWorkerAPI(key: string): Promise<DocWorkerAPI> {
|
||||||
@ -1163,6 +1163,27 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents information to build public doc worker url.
|
||||||
|
*
|
||||||
|
* Structure that may contain either **exclusively**:
|
||||||
|
* - a selfPrefix when no pool of doc worker exist.
|
||||||
|
* - a public doc worker url otherwise.
|
||||||
|
*/
|
||||||
|
export type PublicDocWorkerUrlInfo = {
|
||||||
|
selfPrefix: string;
|
||||||
|
docWorkerUrl: null;
|
||||||
|
} | {
|
||||||
|
selfPrefix: null;
|
||||||
|
docWorkerUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUrlFromPrefix(homeUrl: string, prefix: string) {
|
||||||
|
const url = new URL(homeUrl);
|
||||||
|
url.pathname = prefix + url.pathname;
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a docWorkerUrl from information returned from backend. When the backend
|
* Get a docWorkerUrl from information returned from backend. When the backend
|
||||||
* is fully configured, and there is a pool of workers, this is straightforward,
|
* is fully configured, and there is a pool of workers, this is straightforward,
|
||||||
@ -1171,19 +1192,13 @@ export class DocAPIImpl extends BaseAPI implements DocAPI {
|
|||||||
* use the homeUrl of the backend, with extra path prefix information
|
* use the homeUrl of the backend, with extra path prefix information
|
||||||
* given by selfPrefix. At the time of writing, the selfPrefix contains a
|
* given by selfPrefix. At the time of writing, the selfPrefix contains a
|
||||||
* doc-worker id, and a tag for the codebase (used in consistency checks).
|
* doc-worker id, and a tag for the codebase (used in consistency checks).
|
||||||
|
*
|
||||||
|
* @param {string} homeUrl
|
||||||
|
* @param {string} docWorkerInfo The information to build the public doc worker url
|
||||||
|
* (result of the call to /api/worker/:docId)
|
||||||
*/
|
*/
|
||||||
export function getDocWorkerUrl(homeUrl: string, docWorkerInfo: {
|
export function getPublicDocWorkerUrl(homeUrl: string, docWorkerInfo: PublicDocWorkerUrlInfo) {
|
||||||
docWorkerUrl: string|null,
|
return docWorkerInfo.selfPrefix !== null ?
|
||||||
selfPrefix?: string,
|
getUrlFromPrefix(homeUrl, docWorkerInfo.selfPrefix) :
|
||||||
}): string {
|
docWorkerInfo.docWorkerUrl;
|
||||||
if (!docWorkerInfo.docWorkerUrl) {
|
|
||||||
if (!docWorkerInfo.selfPrefix) {
|
|
||||||
// This should never happen.
|
|
||||||
throw new Error('missing selfPrefix for docWorkerUrl');
|
|
||||||
}
|
|
||||||
const url = new URL(homeUrl);
|
|
||||||
url.pathname = docWorkerInfo.selfPrefix + url.pathname;
|
|
||||||
return url.href;
|
|
||||||
}
|
|
||||||
return docWorkerInfo.docWorkerUrl;
|
|
||||||
}
|
}
|
||||||
|
@ -91,6 +91,7 @@ export const commonUrls = {
|
|||||||
helpAPI: 'https://support.getgrist.com/api',
|
helpAPI: 'https://support.getgrist.com/api',
|
||||||
freeCoachingCall: getFreeCoachingCallUrl(),
|
freeCoachingCall: getFreeCoachingCallUrl(),
|
||||||
contactSupport: getContactSupportUrl(),
|
contactSupport: getContactSupportUrl(),
|
||||||
|
termsOfService: getTermsOfServiceUrl(),
|
||||||
plans: "https://www.getgrist.com/pricing",
|
plans: "https://www.getgrist.com/pricing",
|
||||||
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
sproutsProgram: "https://www.getgrist.com/sprouts-program",
|
||||||
contact: "https://www.getgrist.com/contact",
|
contact: "https://www.getgrist.com/contact",
|
||||||
@ -187,10 +188,23 @@ export interface OrgUrlInfo {
|
|||||||
orgInPath?: string; // If /o/{orgInPath} should be used to access the requested org.
|
orgInPath?: string; // If /o/{orgInPath} should be used to access the requested org.
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDocInternalUrl(host: string) {
|
function hostMatchesUrl(host?: string, url?: string) {
|
||||||
if (!process.env.APP_DOC_INTERNAL_URL) { return false; }
|
return host !== undefined && url !== undefined && new URL(url).host === host;
|
||||||
const internalUrl = new URL('/', process.env.APP_DOC_INTERNAL_URL);
|
}
|
||||||
return internalUrl.host === host;
|
|
||||||
|
/**
|
||||||
|
* Returns true if:
|
||||||
|
* - the server is a home worker and the host matches APP_HOME_INTERNAL_URL;
|
||||||
|
* - or the server is a doc worker and the host matches APP_DOC_INTERNAL_URL;
|
||||||
|
*
|
||||||
|
* @param {string?} host The host to check
|
||||||
|
*/
|
||||||
|
function isOwnInternalUrlHost(host?: string) {
|
||||||
|
// Note: APP_HOME_INTERNAL_URL may also be defined in doc worker as well as in home worker
|
||||||
|
if (process.env.APP_HOME_INTERNAL_URL && hostMatchesUrl(host, process.env.APP_HOME_INTERNAL_URL)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Boolean(process.env.APP_DOC_INTERNAL_URL) && hostMatchesUrl(host, process.env.APP_DOC_INTERNAL_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -210,7 +224,11 @@ export function getHostType(host: string, options: {
|
|||||||
|
|
||||||
const hostname = host.split(":")[0];
|
const hostname = host.split(":")[0];
|
||||||
if (!options.baseDomain) { return 'native'; }
|
if (!options.baseDomain) { return 'native'; }
|
||||||
if (hostname === 'localhost' || isDocInternalUrl(host) || hostname.endsWith(options.baseDomain)) {
|
if (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
isOwnInternalUrlHost(host) ||
|
||||||
|
hostname.endsWith(options.baseDomain)
|
||||||
|
) {
|
||||||
return 'native';
|
return 'native';
|
||||||
}
|
}
|
||||||
return 'custom';
|
return 'custom';
|
||||||
@ -676,6 +694,9 @@ export interface GristLoadConfig {
|
|||||||
// Url for support for the browser client to use.
|
// Url for support for the browser client to use.
|
||||||
helpCenterUrl?: string;
|
helpCenterUrl?: string;
|
||||||
|
|
||||||
|
// Url for terms of service for the browser client to use
|
||||||
|
termsOfServiceUrl?: string;
|
||||||
|
|
||||||
// Url for free coaching call scheduling for the browser client to use.
|
// Url for free coaching call scheduling for the browser client to use.
|
||||||
freeCoachingCallUrl?: string;
|
freeCoachingCallUrl?: string;
|
||||||
|
|
||||||
@ -887,6 +908,15 @@ export function getHelpCenterUrl(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTermsOfServiceUrl(): string|undefined {
|
||||||
|
if(isClient()) {
|
||||||
|
const gristConfig: GristLoadConfig = (window as any).gristConfig;
|
||||||
|
return gristConfig && gristConfig.termsOfServiceUrl || undefined;
|
||||||
|
} else {
|
||||||
|
return process.env.GRIST_TERMS_OF_SERVICE_URL || undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getFreeCoachingCallUrl(): string {
|
export function getFreeCoachingCallUrl(): string {
|
||||||
const defaultUrl = "https://calendly.com/grist-team/grist-free-coaching-call";
|
const defaultUrl = "https://calendly.com/grist-team/grist-free-coaching-call";
|
||||||
if(isClient()) {
|
if(isClient()) {
|
||||||
|
@ -104,7 +104,11 @@ export class DocApiForwarder {
|
|||||||
url.pathname = removeTrailingSlash(docWorkerUrl.pathname) + url.pathname;
|
url.pathname = removeTrailingSlash(docWorkerUrl.pathname) + url.pathname;
|
||||||
|
|
||||||
const headers: {[key: string]: string} = {
|
const headers: {[key: string]: string} = {
|
||||||
...getTransitiveHeaders(req),
|
// At this point, we have already checked and trusted the origin of the request.
|
||||||
|
// See FlexServer#addApiMiddleware(). So don't include the "Origin" header.
|
||||||
|
// Including this header also would break features like form submissions,
|
||||||
|
// as the "Host" header is not retrieved when calling getTransitiveHeaders().
|
||||||
|
...getTransitiveHeaders(req, { includeOrigin: false }),
|
||||||
'Content-Type': req.get('Content-Type') || 'application/json',
|
'Content-Type': req.get('Content-Type') || 'application/json',
|
||||||
};
|
};
|
||||||
for (const key of ['X-Sort', 'X-Limit']) {
|
for (const key of ['X-Sort', 'X-Limit']) {
|
||||||
|
@ -9,17 +9,18 @@ import {ApiError} from 'app/common/ApiError';
|
|||||||
import {getSlugIfNeeded, parseUrlId, SHARE_KEY_PREFIX} from 'app/common/gristUrls';
|
import {getSlugIfNeeded, parseUrlId, SHARE_KEY_PREFIX} from 'app/common/gristUrls';
|
||||||
import {LocalPlugin} from "app/common/plugin";
|
import {LocalPlugin} from "app/common/plugin";
|
||||||
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
|
import {TELEMETRY_TEMPLATE_SIGNUP_COOKIE_NAME} from 'app/common/Telemetry';
|
||||||
import {Document as APIDocument} from 'app/common/UserAPI';
|
import {Document as APIDocument, PublicDocWorkerUrlInfo} from 'app/common/UserAPI';
|
||||||
import {Document} from "app/gen-server/entity/Document";
|
import {Document} from "app/gen-server/entity/Document";
|
||||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
|
||||||
import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser,
|
import {assertAccess, getTransitiveHeaders, getUserId, isAnonymousUser,
|
||||||
RequestWithLogin} from 'app/server/lib/Authorizer';
|
RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
|
import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
|
||||||
import {customizeDocWorkerUrl, getWorker, useWorkerPool} from 'app/server/lib/DocWorkerUtils';
|
import {
|
||||||
|
customizeDocWorkerUrl, getDocWorkerInfoOrSelfPrefix, getWorker, useWorkerPool
|
||||||
|
} from 'app/server/lib/DocWorkerUtils';
|
||||||
import {expressWrap} from 'app/server/lib/expressWrap';
|
import {expressWrap} from 'app/server/lib/expressWrap';
|
||||||
import {DocTemplate, GristServer} from 'app/server/lib/GristServer';
|
import {DocTemplate, GristServer} from 'app/server/lib/GristServer';
|
||||||
import {getCookieDomain} from 'app/server/lib/gristSessions';
|
import {getCookieDomain} from 'app/server/lib/gristSessions';
|
||||||
import {getAssignmentId} from 'app/server/lib/idUtils';
|
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
import {addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils';
|
import {addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils';
|
||||||
import {ISendAppPageOptions} from 'app/server/lib/sendAppPage';
|
import {ISendAppPageOptions} from 'app/server/lib/sendAppPage';
|
||||||
@ -48,32 +49,18 @@ export function attachAppEndpoint(options: AttachOptions): void {
|
|||||||
app.get('/apiconsole', expressWrap(async (req, res) =>
|
app.get('/apiconsole', expressWrap(async (req, res) =>
|
||||||
sendAppPage(req, res, {path: 'apiconsole.html', status: 200, config: {}})));
|
sendAppPage(req, res, {path: 'apiconsole.html', status: 200, config: {}})));
|
||||||
|
|
||||||
app.get('/api/worker/:assignmentId([^/]+)/?*', expressWrap(async (req, res) => {
|
app.get('/api/worker/:docId([^/]+)/?*', expressWrap(async (req, res) => {
|
||||||
if (!useWorkerPool()) {
|
|
||||||
// Let the client know there is not a separate pool of workers,
|
|
||||||
// so they should continue to use the same base URL for accessing
|
|
||||||
// documents. For consistency, return a prefix to add into that
|
|
||||||
// URL, as there would be for a pool of workers. It would be nice
|
|
||||||
// to go ahead and provide the full URL, but that requires making
|
|
||||||
// more assumptions about how Grist is configured.
|
|
||||||
// Alternatives could be: have the client to send their base URL
|
|
||||||
// in the request; or use headers commonly added by reverse proxies.
|
|
||||||
const selfPrefix = "/dw/self/v/" + gristServer.getTag();
|
|
||||||
res.json({docWorkerUrl: null, selfPrefix});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!trustOrigin(req, res)) { throw new Error('Unrecognized origin'); }
|
if (!trustOrigin(req, res)) { throw new Error('Unrecognized origin'); }
|
||||||
res.header("Access-Control-Allow-Credentials", "true");
|
res.header("Access-Control-Allow-Credentials", "true");
|
||||||
|
|
||||||
if (!docWorkerMap) {
|
const {selfPrefix, docWorker} = await getDocWorkerInfoOrSelfPrefix(
|
||||||
return res.status(500).json({error: 'no worker map'});
|
req.params.docId, docWorkerMap, gristServer.getTag()
|
||||||
}
|
);
|
||||||
const assignmentId = getAssignmentId(docWorkerMap, req.params.assignmentId);
|
const info: PublicDocWorkerUrlInfo = selfPrefix ?
|
||||||
const {docStatus} = await getWorker(docWorkerMap, assignmentId, '/status');
|
{ docWorkerUrl: null, selfPrefix } :
|
||||||
if (!docStatus) {
|
{ docWorkerUrl: customizeDocWorkerUrl(docWorker!.publicUrl, req), selfPrefix: null };
|
||||||
return res.status(500).json({error: 'no worker'});
|
|
||||||
}
|
return res.json(info);
|
||||||
res.json({docWorkerUrl: customizeDocWorkerUrl(docStatus.docWorker.publicUrl, req)});
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Handler for serving the document landing pages. Expects the following parameters:
|
// Handler for serving the document landing pages. Expects the following parameters:
|
||||||
@ -160,7 +147,7 @@ export function attachAppEndpoint(options: AttachOptions): void {
|
|||||||
// TODO docWorkerMain needs to serve app.html, perhaps with correct base-href already set.
|
// TODO docWorkerMain needs to serve app.html, perhaps with correct base-href already set.
|
||||||
const headers = {
|
const headers = {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
...getTransitiveHeaders(req),
|
...getTransitiveHeaders(req, { includeOrigin: true }),
|
||||||
};
|
};
|
||||||
const workerInfo = await getWorker(docWorkerMap, docId, `/${docId}/app.html`, {headers});
|
const workerInfo = await getWorker(docWorkerMap, docId, `/${docId}/app.html`, {headers});
|
||||||
docStatus = workerInfo.docStatus;
|
docStatus = workerInfo.docStatus;
|
||||||
@ -206,10 +193,16 @@ export function attachAppEndpoint(options: AttachOptions): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Without a public URL, we're in single server mode.
|
||||||
|
// Use a null workerPublicURL, to signify that the URL prefix serving the
|
||||||
|
// current endpoint is the only one available.
|
||||||
|
const publicUrl = docStatus?.docWorker?.publicUrl;
|
||||||
|
const workerPublicUrl = publicUrl !== undefined ? customizeDocWorkerUrl(publicUrl, req) : null;
|
||||||
|
|
||||||
await sendAppPage(req, res, {path: "", content: body.page, tag: body.tag, status: 200,
|
await sendAppPage(req, res, {path: "", content: body.page, tag: body.tag, status: 200,
|
||||||
googleTagManager: 'anon', config: {
|
googleTagManager: 'anon', config: {
|
||||||
assignmentId: docId,
|
assignmentId: docId,
|
||||||
getWorker: {[docId]: customizeDocWorkerUrl(docStatus?.docWorker?.publicUrl, req)},
|
getWorker: {[docId]: workerPublicUrl },
|
||||||
getDoc: {[docId]: pruneAPIResult(doc as unknown as APIDocument)},
|
getDoc: {[docId]: pruneAPIResult(doc as unknown as APIDocument)},
|
||||||
plugins
|
plugins
|
||||||
}});
|
}});
|
||||||
|
@ -677,7 +677,10 @@ export function assertAccess(
|
|||||||
* Pull out headers to pass along to a proxied service. Focused primarily on
|
* Pull out headers to pass along to a proxied service. Focused primarily on
|
||||||
* authentication.
|
* authentication.
|
||||||
*/
|
*/
|
||||||
export function getTransitiveHeaders(req: Request): {[key: string]: string} {
|
export function getTransitiveHeaders(
|
||||||
|
req: Request,
|
||||||
|
{ includeOrigin }: { includeOrigin: boolean }
|
||||||
|
): {[key: string]: string} {
|
||||||
const Authorization = req.get('Authorization');
|
const Authorization = req.get('Authorization');
|
||||||
const Cookie = req.get('Cookie');
|
const Cookie = req.get('Cookie');
|
||||||
const PermitHeader = req.get('Permit');
|
const PermitHeader = req.get('Permit');
|
||||||
@ -685,13 +688,14 @@ export function getTransitiveHeaders(req: Request): {[key: string]: string} {
|
|||||||
const XRequestedWith = req.get('X-Requested-With');
|
const XRequestedWith = req.get('X-Requested-With');
|
||||||
const Origin = req.get('Origin'); // Pass along the original Origin since it may
|
const Origin = req.get('Origin'); // Pass along the original Origin since it may
|
||||||
// play a role in granular access control.
|
// play a role in granular access control.
|
||||||
|
|
||||||
const result: Record<string, string> = {
|
const result: Record<string, string> = {
|
||||||
...(Authorization ? { Authorization } : undefined),
|
...(Authorization ? { Authorization } : undefined),
|
||||||
...(Cookie ? { Cookie } : undefined),
|
...(Cookie ? { Cookie } : undefined),
|
||||||
...(Organization ? { Organization } : undefined),
|
...(Organization ? { Organization } : undefined),
|
||||||
...(PermitHeader ? { Permit: PermitHeader } : undefined),
|
...(PermitHeader ? { Permit: PermitHeader } : undefined),
|
||||||
...(XRequestedWith ? { 'X-Requested-With': XRequestedWith } : undefined),
|
...(XRequestedWith ? { 'X-Requested-With': XRequestedWith } : undefined),
|
||||||
...(Origin ? { Origin } : undefined),
|
...((includeOrigin && Origin) ? { Origin } : undefined),
|
||||||
};
|
};
|
||||||
const extraHeader = process.env.GRIST_FORWARD_AUTH_HEADER;
|
const extraHeader = process.env.GRIST_FORWARD_AUTH_HEADER;
|
||||||
const extraHeaderValue = extraHeader && req.get(extraHeader);
|
const extraHeaderValue = extraHeader && req.get(extraHeader);
|
||||||
|
@ -58,6 +58,7 @@ export class BootProbes {
|
|||||||
this._probes.push(_bootProbe);
|
this._probes.push(_bootProbe);
|
||||||
this._probes.push(_hostHeaderProbe);
|
this._probes.push(_hostHeaderProbe);
|
||||||
this._probes.push(_sandboxingProbe);
|
this._probes.push(_sandboxingProbe);
|
||||||
|
this._probes.push(_authenticationProbe);
|
||||||
this._probeById = new Map(this._probes.map(p => [p.id, p]));
|
this._probeById = new Map(this._probes.map(p => [p.id, p]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -77,7 +78,7 @@ const _homeUrlReachableProbe: Probe = {
|
|||||||
id: 'reachable',
|
id: 'reachable',
|
||||||
name: 'Grist is reachable',
|
name: 'Grist is reachable',
|
||||||
apply: async (server, req) => {
|
apply: async (server, req) => {
|
||||||
const url = server.getHomeUrl(req);
|
const url = server.getHomeInternalUrl();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
if (resp.status !== 200) {
|
if (resp.status !== 200) {
|
||||||
@ -102,7 +103,7 @@ const _statusCheckProbe: Probe = {
|
|||||||
id: 'health-check',
|
id: 'health-check',
|
||||||
name: 'Built-in Health check',
|
name: 'Built-in Health check',
|
||||||
apply: async (server, req) => {
|
apply: async (server, req) => {
|
||||||
const baseUrl = server.getHomeUrl(req);
|
const baseUrl = server.getHomeInternalUrl();
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
url.pathname = removeTrailingSlash(url.pathname) + '/status';
|
url.pathname = removeTrailingSlash(url.pathname) + '/status';
|
||||||
try {
|
try {
|
||||||
@ -202,3 +203,17 @@ const _sandboxingProbe: Probe = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _authenticationProbe: Probe = {
|
||||||
|
id: 'authentication',
|
||||||
|
name: 'Authentication system',
|
||||||
|
apply: async(server, req) => {
|
||||||
|
const loginSystemId = server.getInfo('loginMiddlewareComment');
|
||||||
|
return {
|
||||||
|
success: loginSystemId != undefined,
|
||||||
|
details: {
|
||||||
|
loginSystemId,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -1098,10 +1098,11 @@ export class DocWorkerApi {
|
|||||||
if (req.body.sourceDocId) {
|
if (req.body.sourceDocId) {
|
||||||
options.sourceDocId = await this._confirmDocIdForRead(req, String(req.body.sourceDocId));
|
options.sourceDocId = await this._confirmDocIdForRead(req, String(req.body.sourceDocId));
|
||||||
// Make sure that if we wanted to download the full source, we would be allowed.
|
// Make sure that if we wanted to download the full source, we would be allowed.
|
||||||
const result = await fetch(this._grist.getHomeUrl(req, `/api/docs/${options.sourceDocId}/download?dryrun=1`), {
|
const homeUrl = this._grist.getHomeInternalUrl(`/api/docs/${options.sourceDocId}/download?dryrun=1`);
|
||||||
|
const result = await fetch(homeUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
...getTransitiveHeaders(req),
|
...getTransitiveHeaders(req, { includeOrigin: false }),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1111,10 +1112,10 @@ export class DocWorkerApi {
|
|||||||
}
|
}
|
||||||
// We should make sure the source document has flushed recently.
|
// We should make sure the source document has flushed recently.
|
||||||
// It may not be served by the same worker, so work through the api.
|
// It may not be served by the same worker, so work through the api.
|
||||||
await fetch(this._grist.getHomeUrl(req, `/api/docs/${options.sourceDocId}/flush`), {
|
await fetch(this._grist.getHomeInternalUrl(`/api/docs/${options.sourceDocId}/flush`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...getTransitiveHeaders(req),
|
...getTransitiveHeaders(req, { includeOrigin: false }),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1170,12 +1171,16 @@ export class DocWorkerApi {
|
|||||||
const showDetails = isAffirmative(req.query.detail);
|
const showDetails = isAffirmative(req.query.detail);
|
||||||
const docSession = docSessionFromRequest(req);
|
const docSession = docSessionFromRequest(req);
|
||||||
const {states} = await this._getStates(docSession, activeDoc);
|
const {states} = await this._getStates(docSession, activeDoc);
|
||||||
const ref = await fetch(this._grist.getHomeUrl(req, `/api/docs/${req.params.docId2}/states`), {
|
const ref = await fetch(this._grist.getHomeInternalUrl(`/api/docs/${req.params.docId2}/states`), {
|
||||||
headers: {
|
headers: {
|
||||||
...getTransitiveHeaders(req),
|
...getTransitiveHeaders(req, { includeOrigin: false }),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (!ref.ok) {
|
||||||
|
res.status(ref.status).send(await ref.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
const states2: DocState[] = (await ref.json()).states;
|
const states2: DocState[] = (await ref.json()).states;
|
||||||
const left = states[0];
|
const left = states[0];
|
||||||
const right = states2[0];
|
const right = states2[0];
|
||||||
@ -1199,9 +1204,9 @@ export class DocWorkerApi {
|
|||||||
|
|
||||||
// Calculate changes from the (common) parent to the current version of the other document.
|
// Calculate changes from the (common) parent to the current version of the other document.
|
||||||
const url = `/api/docs/${req.params.docId2}/compare?left=${parent.h}`;
|
const url = `/api/docs/${req.params.docId2}/compare?left=${parent.h}`;
|
||||||
const rightChangesReq = await fetch(this._grist.getHomeUrl(req, url), {
|
const rightChangesReq = await fetch(this._grist.getHomeInternalUrl(url), {
|
||||||
headers: {
|
headers: {
|
||||||
...getTransitiveHeaders(req),
|
...getTransitiveHeaders(req, { includeOrigin: false }),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1644,7 +1649,7 @@ export class DocWorkerApi {
|
|||||||
let uploadResult;
|
let uploadResult;
|
||||||
try {
|
try {
|
||||||
const accessId = makeAccessId(req, getAuthorizedUserId(req));
|
const accessId = makeAccessId(req, getAuthorizedUserId(req));
|
||||||
uploadResult = await fetchDoc(this._grist, sourceDocumentId, req, accessId, asTemplate);
|
uploadResult = await fetchDoc(this._grist, this._docWorkerMap, sourceDocumentId, req, accessId, asTemplate);
|
||||||
globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, `${documentName}.grist`);
|
globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, `${documentName}.grist`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as ApiError).status === 403) {
|
if ((err as ApiError).status === 403) {
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import {ApiError} from 'app/common/ApiError';
|
import {ApiError} from 'app/common/ApiError';
|
||||||
import {parseSubdomainStrictly} from 'app/common/gristUrls';
|
import {parseSubdomainStrictly} from 'app/common/gristUrls';
|
||||||
import {removeTrailingSlash} from 'app/common/gutil';
|
import {removeTrailingSlash} from 'app/common/gutil';
|
||||||
import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
|
import {DocStatus, DocWorkerInfo, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
|
||||||
import log from 'app/server/lib/log';
|
import log from 'app/server/lib/log';
|
||||||
import {adaptServerUrl} from 'app/server/lib/requestUtils';
|
import {adaptServerUrl} from 'app/server/lib/requestUtils';
|
||||||
import * as express from 'express';
|
import * as express from 'express';
|
||||||
import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
|
import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
|
||||||
|
import {getAssignmentId} from './idUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method transforms a doc worker's public url as needed based on the request.
|
* This method transforms a doc worker's public url as needed based on the request.
|
||||||
@ -35,16 +36,7 @@ import fetch, {Response as FetchResponse, RequestInit} from 'node-fetch';
|
|||||||
* TODO: doc worker registration could be redesigned to remove the assumption
|
* TODO: doc worker registration could be redesigned to remove the assumption
|
||||||
* of a fixed base domain.
|
* of a fixed base domain.
|
||||||
*/
|
*/
|
||||||
export function customizeDocWorkerUrl(
|
export function customizeDocWorkerUrl( docWorkerUrlSeed: string, req: express.Request): string {
|
||||||
docWorkerUrlSeed: string|undefined,
|
|
||||||
req: express.Request
|
|
||||||
): string|null {
|
|
||||||
if (!docWorkerUrlSeed) {
|
|
||||||
// When no doc worker seed, we're in single server mode.
|
|
||||||
// Return null, to signify that the URL prefix serving the
|
|
||||||
// current endpoint is the only one available.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const docWorkerUrl = new URL(docWorkerUrlSeed);
|
const docWorkerUrl = new URL(docWorkerUrlSeed);
|
||||||
const workerSubdomain = parseSubdomainStrictly(docWorkerUrl.hostname).org;
|
const workerSubdomain = parseSubdomainStrictly(docWorkerUrl.hostname).org;
|
||||||
adaptServerUrl(docWorkerUrl, req);
|
adaptServerUrl(docWorkerUrl, req);
|
||||||
@ -152,6 +144,43 @@ export async function getWorker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DocWorkerInfoOrSelfPrefix = {
|
||||||
|
docWorker: DocWorkerInfo,
|
||||||
|
selfPrefix?: never,
|
||||||
|
} | {
|
||||||
|
docWorker?: never,
|
||||||
|
selfPrefix: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getDocWorkerInfoOrSelfPrefix(
|
||||||
|
docId: string,
|
||||||
|
docWorkerMap?: IDocWorkerMap | null,
|
||||||
|
tag?: string
|
||||||
|
): Promise<DocWorkerInfoOrSelfPrefix> {
|
||||||
|
if (!useWorkerPool()) {
|
||||||
|
// Let the client know there is not a separate pool of workers,
|
||||||
|
// so they should continue to use the same base URL for accessing
|
||||||
|
// documents. For consistency, return a prefix to add into that
|
||||||
|
// URL, as there would be for a pool of workers. It would be nice
|
||||||
|
// to go ahead and provide the full URL, but that requires making
|
||||||
|
// more assumptions about how Grist is configured.
|
||||||
|
// Alternatives could be: have the client to send their base URL
|
||||||
|
// in the request; or use headers commonly added by reverse proxies.
|
||||||
|
const selfPrefix = "/dw/self/v/" + tag;
|
||||||
|
return { selfPrefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!docWorkerMap) {
|
||||||
|
throw new Error('no worker map');
|
||||||
|
}
|
||||||
|
const assignmentId = getAssignmentId(docWorkerMap, docId);
|
||||||
|
const { docStatus } = await getWorker(docWorkerMap, assignmentId, '/status');
|
||||||
|
if (!docStatus) {
|
||||||
|
throw new Error('no worker');
|
||||||
|
}
|
||||||
|
return { docWorker: docStatus.docWorker };
|
||||||
|
}
|
||||||
|
|
||||||
// Return true if document related endpoints are served by separate workers.
|
// Return true if document related endpoints are served by separate workers.
|
||||||
export function useWorkerPool() {
|
export function useWorkerPool() {
|
||||||
return process.env.GRIST_SINGLE_PORT !== 'true';
|
return process.env.GRIST_SINGLE_PORT !== 'true';
|
||||||
|
@ -295,6 +295,13 @@ export class FlexServer implements GristServer {
|
|||||||
return homeUrl;
|
return homeUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as getDefaultHomeUrl, but for internal use.
|
||||||
|
*/
|
||||||
|
public getDefaultHomeInternalUrl(): string {
|
||||||
|
return process.env.APP_HOME_INTERNAL_URL || this.getDefaultHomeUrl();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a url for the home server api, adapting it to match the base domain in the
|
* Get a url for the home server api, adapting it to match the base domain in the
|
||||||
* requested url. This adaptation is important for cookie-based authentication.
|
* requested url. This adaptation is important for cookie-based authentication.
|
||||||
@ -309,6 +316,14 @@ export class FlexServer implements GristServer {
|
|||||||
return homeUrl.href;
|
return homeUrl.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as getHomeUrl, but for requesting internally.
|
||||||
|
*/
|
||||||
|
public getHomeInternalUrl(relPath: string = ''): string {
|
||||||
|
const homeUrl = new URL(relPath, this.getDefaultHomeInternalUrl());
|
||||||
|
return homeUrl.href;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a home url that is appropriate for the given document. For now, this
|
* Get a home url that is appropriate for the given document. For now, this
|
||||||
* returns a default that works for all documents. That could change in future,
|
* returns a default that works for all documents. That could change in future,
|
||||||
@ -316,7 +331,7 @@ export class FlexServer implements GristServer {
|
|||||||
* based on domain).
|
* based on domain).
|
||||||
*/
|
*/
|
||||||
public async getHomeUrlByDocId(docId: string, relPath: string = ''): Promise<string> {
|
public async getHomeUrlByDocId(docId: string, relPath: string = ''): Promise<string> {
|
||||||
return new URL(relPath, this.getDefaultHomeUrl()).href;
|
return new URL(relPath, this.getDefaultHomeInternalUrl()).href;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the port number the server listens on. This may be different from the port
|
// Get the port number the server listens on. This may be different from the port
|
||||||
@ -1411,6 +1426,11 @@ export class FlexServer implements GristServer {
|
|||||||
return this._sandboxInfo;
|
return this._sandboxInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getInfo(key: string): any {
|
||||||
|
const infoPair = this.info.find(([keyToCheck]) => key === keyToCheck);
|
||||||
|
return infoPair?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
public disableExternalStorage() {
|
public disableExternalStorage() {
|
||||||
if (this.deps.has('doc')) {
|
if (this.deps.has('doc')) {
|
||||||
throw new Error('disableExternalStorage called too late');
|
throw new Error('disableExternalStorage called too late');
|
||||||
@ -1429,12 +1449,12 @@ export class FlexServer implements GristServer {
|
|||||||
return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
|
return this._sendAppPage(req, resp, {path: 'app.html', status: 200, config: {}});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createDoom = async (req: express.Request) => {
|
const createDoom = async () => {
|
||||||
const dbManager = this.getHomeDBManager();
|
const dbManager = this.getHomeDBManager();
|
||||||
const permitStore = this.getPermitStore();
|
const permitStore = this.getPermitStore();
|
||||||
const notifier = this.getNotifier();
|
const notifier = this.getNotifier();
|
||||||
const loginSystem = await this.resolveLoginSystem();
|
const loginSystem = await this.resolveLoginSystem();
|
||||||
const homeUrl = this.getHomeUrl(req).replace(/\/$/, '');
|
const homeUrl = this.getHomeInternalUrl().replace(/\/$/, '');
|
||||||
return new Doom(dbManager, permitStore, notifier, loginSystem, homeUrl);
|
return new Doom(dbManager, permitStore, notifier, loginSystem, homeUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1458,7 +1478,7 @@ export class FlexServer implements GristServer {
|
|||||||
|
|
||||||
// Reuse Doom cli tool for account deletion. It won't allow to delete account if it has access
|
// Reuse Doom cli tool for account deletion. It won't allow to delete account if it has access
|
||||||
// to other (not public) team sites.
|
// to other (not public) team sites.
|
||||||
const doom = await createDoom(req);
|
const doom = await createDoom();
|
||||||
await doom.deleteUser(userId);
|
await doom.deleteUser(userId);
|
||||||
this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedAccount');
|
this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedAccount');
|
||||||
return resp.status(200).json(true);
|
return resp.status(200).json(true);
|
||||||
@ -1491,7 +1511,7 @@ export class FlexServer implements GristServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reuse Doom cli tool for org deletion. Note, this removes everything as a super user.
|
// Reuse Doom cli tool for org deletion. Note, this removes everything as a super user.
|
||||||
const doom = await createDoom(req);
|
const doom = await createDoom();
|
||||||
await doom.deleteOrg(org.id);
|
await doom.deleteOrg(org.id);
|
||||||
|
|
||||||
this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedSite', {
|
this.getTelemetry().logEvent(req as RequestWithLogin, 'deletedSite', {
|
||||||
@ -1980,7 +2000,7 @@ export class FlexServer implements GristServer {
|
|||||||
// Add the handling for the /upload route. Most uploads are meant for a DocWorker: they are put
|
// Add the handling for the /upload route. Most uploads are meant for a DocWorker: they are put
|
||||||
// in temporary files, and the DocWorker needs to be on the same machine to have access to them.
|
// in temporary files, and the DocWorker needs to be on the same machine to have access to them.
|
||||||
// This doesn't check for doc access permissions because the request isn't tied to a document.
|
// This doesn't check for doc access permissions because the request isn't tied to a document.
|
||||||
addUploadRoute(this, this.app, this._trustOriginsMiddleware, ...basicMiddleware);
|
addUploadRoute(this, this.app, this._docWorkerMap, this._trustOriginsMiddleware, ...basicMiddleware);
|
||||||
|
|
||||||
this.app.get('/attachment', ...docAccessMiddleware,
|
this.app.get('/attachment', ...docAccessMiddleware,
|
||||||
expressWrap(async (req, res) => this._docWorker.getAttachment(req, res)));
|
expressWrap(async (req, res) => this._docWorker.getAttachment(req, res)));
|
||||||
@ -2418,10 +2438,10 @@ export class FlexServer implements GristServer {
|
|||||||
const workspace = workspaces.find(w => w.name === 'Home');
|
const workspace = workspaces.find(w => w.name === 'Home');
|
||||||
if (!workspace) { throw new Error('Home workspace not found'); }
|
if (!workspace) { throw new Error('Home workspace not found'); }
|
||||||
|
|
||||||
const copyDocUrl = this.getHomeUrl(req, '/api/docs');
|
const copyDocUrl = this.getHomeInternalUrl('/api/docs');
|
||||||
const response = await fetch(copyDocUrl, {
|
const response = await fetch(copyDocUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
...getTransitiveHeaders(req),
|
...getTransitiveHeaders(req, { includeOrigin: false }),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
@ -35,6 +35,7 @@ export interface GristServer {
|
|||||||
settings?: Readonly<Record<string, unknown>>;
|
settings?: Readonly<Record<string, unknown>>;
|
||||||
getHost(): string;
|
getHost(): string;
|
||||||
getHomeUrl(req: express.Request, relPath?: string): string;
|
getHomeUrl(req: express.Request, relPath?: string): string;
|
||||||
|
getHomeInternalUrl(relPath?: string): string;
|
||||||
getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;
|
getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;
|
||||||
getOwnUrl(): string;
|
getOwnUrl(): string;
|
||||||
getOrgUrl(orgKey: string|number): Promise<string>;
|
getOrgUrl(orgKey: string|number): Promise<string>;
|
||||||
@ -66,6 +67,7 @@ export interface GristServer {
|
|||||||
getBundledWidgets(): ICustomWidget[];
|
getBundledWidgets(): ICustomWidget[];
|
||||||
hasBoot(): boolean;
|
hasBoot(): boolean;
|
||||||
getSandboxInfo(): SandboxInfo|undefined;
|
getSandboxInfo(): SandboxInfo|undefined;
|
||||||
|
getInfo(key: string): any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GristLoginSystem {
|
export interface GristLoginSystem {
|
||||||
@ -127,6 +129,7 @@ export function createDummyGristServer(): GristServer {
|
|||||||
settings: {},
|
settings: {},
|
||||||
getHost() { return 'localhost:4242'; },
|
getHost() { return 'localhost:4242'; },
|
||||||
getHomeUrl() { return 'http://localhost:4242'; },
|
getHomeUrl() { return 'http://localhost:4242'; },
|
||||||
|
getHomeInternalUrl() { return 'http://localhost:4242'; },
|
||||||
getHomeUrlByDocId() { return Promise.resolve('http://localhost:4242'); },
|
getHomeUrlByDocId() { return Promise.resolve('http://localhost:4242'); },
|
||||||
getMergedOrgUrl() { return 'http://localhost:4242'; },
|
getMergedOrgUrl() { return 'http://localhost:4242'; },
|
||||||
getOwnUrl() { return 'http://localhost:4242'; },
|
getOwnUrl() { return 'http://localhost:4242'; },
|
||||||
@ -157,6 +160,7 @@ export function createDummyGristServer(): GristServer {
|
|||||||
getBundledWidgets() { return []; },
|
getBundledWidgets() { return []; },
|
||||||
hasBoot() { return false; },
|
hasBoot() { return false; },
|
||||||
getSandboxInfo() { return undefined; },
|
getSandboxInfo() { return undefined; },
|
||||||
|
getInfo(key: string) { return undefined; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,7 +11,6 @@ export const ClientJsonMemoryLimits = t.iface([], {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const ITestingHooks = t.iface([], {
|
export const ITestingHooks = t.iface([], {
|
||||||
"getOwnPort": t.func("number"),
|
|
||||||
"getPort": t.func("number"),
|
"getPort": t.func("number"),
|
||||||
"setLoginSessionProfile": t.func("void", t.param("gristSidCookie", "string"), t.param("profile", t.union("UserProfile", "null")), t.param("org", "string", true)),
|
"setLoginSessionProfile": t.func("void", t.param("gristSidCookie", "string"), t.param("profile", t.union("UserProfile", "null")), t.param("org", "string", true)),
|
||||||
"setServerVersion": t.func("void", t.param("version", t.union("string", "null"))),
|
"setServerVersion": t.func("void", t.param("version", t.union("string", "null"))),
|
||||||
|
@ -7,7 +7,6 @@ export interface ClientJsonMemoryLimits {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ITestingHooks {
|
export interface ITestingHooks {
|
||||||
getOwnPort(): Promise<number>;
|
|
||||||
getPort(): Promise<number>;
|
getPort(): Promise<number>;
|
||||||
setLoginSessionProfile(gristSidCookie: string, profile: UserProfile|null, org?: string): Promise<void>;
|
setLoginSessionProfile(gristSidCookie: string, profile: UserProfile|null, org?: string): Promise<void>;
|
||||||
setServerVersion(version: string|null): Promise<void>;
|
setServerVersion(version: string|null): Promise<void>;
|
||||||
|
@ -68,11 +68,6 @@ export class TestingHooks implements ITestingHooks {
|
|||||||
private _workerServers: FlexServer[]
|
private _workerServers: FlexServer[]
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async getOwnPort(): Promise<number> {
|
|
||||||
log.info("TestingHooks.getOwnPort called");
|
|
||||||
return this._server.getOwnPort();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getPort(): Promise<number> {
|
public async getPort(): Promise<number> {
|
||||||
log.info("TestingHooks.getPort called");
|
log.info("TestingHooks.getPort called");
|
||||||
return this._port;
|
return this._port;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {ApiError} from 'app/common/ApiError';
|
import {ApiError} from 'app/common/ApiError';
|
||||||
import {DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail} from 'app/common/gristUrls';
|
import { DEFAULT_HOME_SUBDOMAIN, isOrgInPathOnly, parseSubdomain, sanitizePathTail } from 'app/common/gristUrls';
|
||||||
import * as gutil from 'app/common/gutil';
|
import * as gutil from 'app/common/gutil';
|
||||||
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
|
import {DocScope, QueryResult, Scope} from 'app/gen-server/lib/HomeDBManager';
|
||||||
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
|
import {getUserId, RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
getFreeCoachingCallUrl,
|
getFreeCoachingCallUrl,
|
||||||
getHelpCenterUrl,
|
getHelpCenterUrl,
|
||||||
getPageTitleSuffix,
|
getPageTitleSuffix,
|
||||||
|
getTermsOfServiceUrl,
|
||||||
GristLoadConfig,
|
GristLoadConfig,
|
||||||
IFeature
|
IFeature
|
||||||
} from 'app/common/gristUrls';
|
} from 'app/common/gristUrls';
|
||||||
@ -62,6 +63,7 @@ export function makeGristConfig(options: MakeGristConfigOptions): GristLoadConfi
|
|||||||
baseDomain,
|
baseDomain,
|
||||||
singleOrg: process.env.GRIST_SINGLE_ORG,
|
singleOrg: process.env.GRIST_SINGLE_ORG,
|
||||||
helpCenterUrl: getHelpCenterUrl(),
|
helpCenterUrl: getHelpCenterUrl(),
|
||||||
|
termsOfServiceUrl: getTermsOfServiceUrl(),
|
||||||
freeCoachingCallUrl: getFreeCoachingCallUrl(),
|
freeCoachingCallUrl: getFreeCoachingCallUrl(),
|
||||||
contactSupportUrl: getContactSupportUrl(),
|
contactSupportUrl: getContactSupportUrl(),
|
||||||
pathOnly,
|
pathOnly,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {ApiError} from 'app/common/ApiError';
|
import {ApiError} from 'app/common/ApiError';
|
||||||
import {InactivityTimer} from 'app/common/InactivityTimer';
|
import {InactivityTimer} from 'app/common/InactivityTimer';
|
||||||
import {FetchUrlOptions, FileUploadResult, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads';
|
import {FetchUrlOptions, FileUploadResult, UPLOAD_URL_PATH, UploadResult} from 'app/common/uploads';
|
||||||
import {getDocWorkerUrl} from 'app/common/UserAPI';
|
import {getUrlFromPrefix} from 'app/common/UserAPI';
|
||||||
import {getAuthorizedUserId, getTransitiveHeaders, getUserId, isSingleUserMode,
|
import {getAuthorizedUserId, getTransitiveHeaders, getUserId, isSingleUserMode,
|
||||||
RequestWithLogin} from 'app/server/lib/Authorizer';
|
RequestWithLogin} from 'app/server/lib/Authorizer';
|
||||||
import {expressWrap} from 'app/server/lib/expressWrap';
|
import {expressWrap} from 'app/server/lib/expressWrap';
|
||||||
@ -21,6 +21,8 @@ import * as multiparty from 'multiparty';
|
|||||||
import fetch, {Response as FetchResponse} from 'node-fetch';
|
import fetch, {Response as FetchResponse} from 'node-fetch';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as tmp from 'tmp';
|
import * as tmp from 'tmp';
|
||||||
|
import {IDocWorkerMap} from './DocWorkerMap';
|
||||||
|
import {getDocWorkerInfoOrSelfPrefix} from './DocWorkerUtils';
|
||||||
|
|
||||||
// After some time of inactivity, clean up the upload. We give an hour, which seems generous,
|
// After some time of inactivity, clean up the upload. We give an hour, which seems generous,
|
||||||
// except that if one is toying with import options, and leaves the upload in an open browser idle
|
// except that if one is toying with import options, and leaves the upload in an open browser idle
|
||||||
@ -39,7 +41,12 @@ export interface FormResult {
|
|||||||
/**
|
/**
|
||||||
* Adds an upload route to the given express app, listening for POST requests at UPLOAD_URL_PATH.
|
* Adds an upload route to the given express app, listening for POST requests at UPLOAD_URL_PATH.
|
||||||
*/
|
*/
|
||||||
export function addUploadRoute(server: GristServer, expressApp: Application, ...handlers: RequestHandler[]): void {
|
export function addUploadRoute(
|
||||||
|
server: GristServer,
|
||||||
|
expressApp: Application,
|
||||||
|
docWorkerMap: IDocWorkerMap,
|
||||||
|
...handlers: RequestHandler[]
|
||||||
|
): void {
|
||||||
|
|
||||||
// When doing a cross-origin post, the browser will check for access with options prior to posting.
|
// When doing a cross-origin post, the browser will check for access with options prior to posting.
|
||||||
// We need to reassure it that the request will be accepted before it will go ahead and post.
|
// We need to reassure it that the request will be accepted before it will go ahead and post.
|
||||||
@ -72,7 +79,7 @@ export function addUploadRoute(server: GristServer, expressApp: Application, ...
|
|||||||
if (!docId) { throw new Error('doc must be specified'); }
|
if (!docId) { throw new Error('doc must be specified'); }
|
||||||
const accessId = makeAccessId(req, getAuthorizedUserId(req));
|
const accessId = makeAccessId(req, getAuthorizedUserId(req));
|
||||||
try {
|
try {
|
||||||
const uploadResult: UploadResult = await fetchDoc(server, docId, req, accessId,
|
const uploadResult: UploadResult = await fetchDoc(server, docWorkerMap, docId, req, accessId,
|
||||||
req.query.template === '1');
|
req.query.template === '1');
|
||||||
if (name) {
|
if (name) {
|
||||||
globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, name);
|
globalUploadSet.changeUploadName(uploadResult.uploadId, accessId, name);
|
||||||
@ -404,24 +411,21 @@ async function _fetchURL(url: string, accessId: string|null, options?: FetchUrlO
|
|||||||
* Fetches a Grist doc potentially managed by a different doc worker. Passes on credentials
|
* Fetches a Grist doc potentially managed by a different doc worker. Passes on credentials
|
||||||
* supplied in the current request.
|
* supplied in the current request.
|
||||||
*/
|
*/
|
||||||
export async function fetchDoc(server: GristServer, docId: string, req: Request, accessId: string|null,
|
export async function fetchDoc(
|
||||||
template: boolean): Promise<UploadResult> {
|
server: GristServer,
|
||||||
|
docWorkerMap: IDocWorkerMap,
|
||||||
|
docId: string,
|
||||||
|
req: Request,
|
||||||
|
accessId: string|null,
|
||||||
|
template: boolean
|
||||||
|
): Promise<UploadResult> {
|
||||||
// Prepare headers that preserve credentials of current user.
|
// Prepare headers that preserve credentials of current user.
|
||||||
const headers = getTransitiveHeaders(req);
|
const headers = getTransitiveHeaders(req, { includeOrigin: false });
|
||||||
|
|
||||||
// Passing the Origin header would serve no purpose here, as we are
|
|
||||||
// constructing an internal request to fetch from our own doc worker
|
|
||||||
// URL. Indeed, it may interfere, as it could incur a CORS check in
|
|
||||||
// `trustOrigin`, which we do not need.
|
|
||||||
delete headers.Origin;
|
|
||||||
|
|
||||||
// Find the doc worker responsible for the document we wish to copy.
|
// Find the doc worker responsible for the document we wish to copy.
|
||||||
// The backend needs to be well configured for this to work.
|
// The backend needs to be well configured for this to work.
|
||||||
const homeUrl = server.getHomeUrl(req);
|
const { selfPrefix, docWorker } = await getDocWorkerInfoOrSelfPrefix(docId, docWorkerMap, server.getTag());
|
||||||
const fetchUrl = new URL(`/api/worker/${docId}`, homeUrl);
|
const docWorkerUrl = docWorker ? docWorker.internalUrl : getUrlFromPrefix(server.getHomeInternalUrl(), selfPrefix);
|
||||||
const response: FetchResponse = await Deps.fetch(fetchUrl.href, {headers});
|
|
||||||
await _checkForError(response);
|
|
||||||
const docWorkerUrl = getDocWorkerUrl(server.getOwnUrl(), await response.json());
|
|
||||||
// Download the document, in full or as a template.
|
// Download the document, in full or as a template.
|
||||||
const url = new URL(`api/docs/${docId}/download?template=${Number(template)}`,
|
const url = new URL(`api/docs/${docId}/download?template=${Number(template)}`,
|
||||||
docWorkerUrl.replace(/\/*$/, '/'));
|
docWorkerUrl.replace(/\/*$/, '/'));
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "grist-core",
|
"name": "grist-core",
|
||||||
"version": "1.1.13",
|
"version": "1.1.14",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"description": "Grist is the evolution of spreadsheets",
|
"description": "Grist is the evolution of spreadsheets",
|
||||||
"homepage": "https://github.com/gristlabs/grist-core",
|
"homepage": "https://github.com/gristlabs/grist-core",
|
||||||
|
@ -17,7 +17,7 @@ The schema for grist data is:
|
|||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import imp
|
import types
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
import six
|
import six
|
||||||
@ -207,7 +207,7 @@ def _is_special_table(table_id):
|
|||||||
|
|
||||||
|
|
||||||
def exec_module_text(module_text):
|
def exec_module_text(module_text):
|
||||||
mod = imp.new_module(codebuilder.code_filename)
|
mod = types.ModuleType(codebuilder.code_filename)
|
||||||
codebuilder.save_to_linecache(module_text)
|
codebuilder.save_to_linecache(module_text)
|
||||||
code_obj = compile(module_text, codebuilder.code_filename, "exec")
|
code_obj = compile(module_text, codebuilder.code_filename, "exec")
|
||||||
# pylint: disable=exec-used
|
# pylint: disable=exec-used
|
||||||
|
1591
static/locales/bg.client.json
Normal file
1591
static/locales/bg.client.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -308,7 +308,34 @@
|
|||||||
"Ok": "OK",
|
"Ok": "OK",
|
||||||
"Webhooks": "Webhaken",
|
"Webhooks": "Webhaken",
|
||||||
"Manage Webhooks": "Webhaken verwalten",
|
"Manage Webhooks": "Webhaken verwalten",
|
||||||
"API Console": "API-Konsole"
|
"API Console": "API-Konsole",
|
||||||
|
"Coming soon": "Demnächst verfügbar",
|
||||||
|
"For number and date formats": "Für Zahlen- und Datumsformate",
|
||||||
|
"Formula times": "Formelzeiten",
|
||||||
|
"Locale": "Lokale",
|
||||||
|
"Time Zone": "Zeitzone",
|
||||||
|
"Find slow formulas": "Langsame Formeln finden",
|
||||||
|
"Manage webhooks": "Webhooks verwalten",
|
||||||
|
"For currency columns": "Für Währungsspalten",
|
||||||
|
"Notify other services on doc changes": "Benachrichtigen Sie andere Dienstleistungen bei doc Änderungen",
|
||||||
|
"Hard reset of data engine": "Hartes Zurücksetzen der Datenmaschine",
|
||||||
|
"Python": "Python",
|
||||||
|
"ID for API use": "ID für API-Verwendung",
|
||||||
|
"Python version used": "Verwendete Python-Version",
|
||||||
|
"Reload": "Neu laden",
|
||||||
|
"Try API calls from the browser": "Versuchen Sie API-Aufrufe über den Browser",
|
||||||
|
"python2 (legacy)": "python2 (veraltet)",
|
||||||
|
"python3 (recommended)": "python3 (empfohlen)",
|
||||||
|
"API URL copied to clipboard": "API-URL in die Zwischenablage kopiert",
|
||||||
|
"Base doc URL: {{docApiUrl}}": "URL des Basisdokuments: {{docApiUrl}}",
|
||||||
|
"API console": "API-Konsole",
|
||||||
|
"API documentation.": "API Dokumentation.",
|
||||||
|
"Copy to clipboard": "In die Zwischenablage kopieren",
|
||||||
|
"Currency": "Währung",
|
||||||
|
"Data Engine": "Datenmaschine",
|
||||||
|
"Default for DateTime columns": "Standard für DateTime-Spalten",
|
||||||
|
"Document ID": "Dokument-ID",
|
||||||
|
"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "Dokument-ID, die bei Aufrufen der REST-API für {{docId}} zu verwenden ist. Siehe {{apiURL}}"
|
||||||
},
|
},
|
||||||
"DocumentUsage": {
|
"DocumentUsage": {
|
||||||
"Attachments Size": "Größe der Anhänge",
|
"Attachments Size": "Größe der Anhänge",
|
||||||
@ -607,7 +634,8 @@
|
|||||||
},
|
},
|
||||||
"OnBoardingPopups": {
|
"OnBoardingPopups": {
|
||||||
"Finish": "Beenden",
|
"Finish": "Beenden",
|
||||||
"Next": "Weiter"
|
"Next": "Weiter",
|
||||||
|
"Previous": "Vorherige"
|
||||||
},
|
},
|
||||||
"OpenVideoTour": {
|
"OpenVideoTour": {
|
||||||
"Grist Video Tour": "Grist Video Tour",
|
"Grist Video Tour": "Grist Video Tour",
|
||||||
@ -981,7 +1009,8 @@
|
|||||||
"Dismiss": "Ablehnen",
|
"Dismiss": "Ablehnen",
|
||||||
"Don't ask again.": "Frag nicht mehr.",
|
"Don't ask again.": "Frag nicht mehr.",
|
||||||
"Don't show again.": "Zeig nicht mehr.",
|
"Don't show again.": "Zeig nicht mehr.",
|
||||||
"Don't show tips": "Keine Tipps anzeigen"
|
"Don't show tips": "Keine Tipps anzeigen",
|
||||||
|
"TIP": "TIPP"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"Duplicate Page": "Seite duplizieren",
|
"Duplicate Page": "Seite duplizieren",
|
||||||
@ -1607,5 +1636,20 @@
|
|||||||
},
|
},
|
||||||
"DropdownConditionEditor": {
|
"DropdownConditionEditor": {
|
||||||
"Enter condition.": "Bedingung eingeben."
|
"Enter condition.": "Bedingung eingeben."
|
||||||
|
},
|
||||||
|
"FormRenderer": {
|
||||||
|
"Reset": "Zurücksetzen",
|
||||||
|
"Submit": "Einreichen",
|
||||||
|
"Search": "Suchen",
|
||||||
|
"Select...": "Wählen Sie..."
|
||||||
|
},
|
||||||
|
"widgetTypesMap": {
|
||||||
|
"Calendar": "Kalender",
|
||||||
|
"Card": "Karte",
|
||||||
|
"Card List": "Kartenliste",
|
||||||
|
"Form": "Formular",
|
||||||
|
"Table": "Tabelle",
|
||||||
|
"Chart": "Diagramm",
|
||||||
|
"Custom": "Benutzerdefiniert"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -300,7 +300,34 @@
|
|||||||
"Ok": "OK",
|
"Ok": "OK",
|
||||||
"Manage Webhooks": "Manage Webhooks",
|
"Manage Webhooks": "Manage Webhooks",
|
||||||
"Webhooks": "Webhooks",
|
"Webhooks": "Webhooks",
|
||||||
"API Console": "API Console"
|
"API Console": "API Console",
|
||||||
|
"API URL copied to clipboard": "API URL copied to clipboard",
|
||||||
|
"API console": "API console",
|
||||||
|
"API documentation.": "API documentation.",
|
||||||
|
"Base doc URL: {{docApiUrl}}": "Base doc URL: {{docApiUrl}}",
|
||||||
|
"Coming soon": "Coming soon",
|
||||||
|
"Copy to clipboard": "Copy to clipboard",
|
||||||
|
"Currency": "Currency",
|
||||||
|
"Data Engine": "Data Engine",
|
||||||
|
"Default for DateTime columns": "Default for DateTime columns",
|
||||||
|
"Document ID": "Document ID",
|
||||||
|
"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}",
|
||||||
|
"Find slow formulas": "Find slow formulas",
|
||||||
|
"For currency columns": "For currency columns",
|
||||||
|
"For number and date formats": "For number and date formats",
|
||||||
|
"Formula times": "Formula times",
|
||||||
|
"Hard reset of data engine": "Hard reset of data engine",
|
||||||
|
"ID for API use": "ID for API use",
|
||||||
|
"Locale": "Locale",
|
||||||
|
"Manage webhooks": "Manage webhooks",
|
||||||
|
"Notify other services on doc changes": "Notify other services on doc changes",
|
||||||
|
"Python": "Python",
|
||||||
|
"Python version used": "Python version used",
|
||||||
|
"Reload": "Reload",
|
||||||
|
"Time Zone": "Time Zone",
|
||||||
|
"Try API calls from the browser": "Try API calls from the browser",
|
||||||
|
"python2 (legacy)": "python2 (legacy)",
|
||||||
|
"python3 (recommended)": "python3 (recommended)"
|
||||||
},
|
},
|
||||||
"DocumentUsage": {
|
"DocumentUsage": {
|
||||||
"Attachments Size": "Size of Attachments",
|
"Attachments Size": "Size of Attachments",
|
||||||
@ -567,7 +594,8 @@
|
|||||||
},
|
},
|
||||||
"OnBoardingPopups": {
|
"OnBoardingPopups": {
|
||||||
"Finish": "Finish",
|
"Finish": "Finish",
|
||||||
"Next": "Next"
|
"Next": "Next",
|
||||||
|
"Previous": "Previous"
|
||||||
},
|
},
|
||||||
"OpenVideoTour": {
|
"OpenVideoTour": {
|
||||||
"Grist Video Tour": "Grist Video Tour",
|
"Grist Video Tour": "Grist Video Tour",
|
||||||
@ -921,7 +949,8 @@
|
|||||||
"Don't show tips": "Don't show tips",
|
"Don't show tips": "Don't show tips",
|
||||||
"Undo to restore": "Undo to restore",
|
"Undo to restore": "Undo to restore",
|
||||||
"Got it": "Got it",
|
"Got it": "Got it",
|
||||||
"Don't show again": "Don't show again"
|
"Don't show again": "Don't show again",
|
||||||
|
"TIP": "TIP"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"Duplicate Page": "Duplicate Page",
|
"Duplicate Page": "Duplicate Page",
|
||||||
@ -1543,5 +1572,20 @@
|
|||||||
"Error in dropdown condition": "Error in dropdown condition",
|
"Error in dropdown condition": "Error in dropdown condition",
|
||||||
"No choices matching condition": "No choices matching condition",
|
"No choices matching condition": "No choices matching condition",
|
||||||
"No choices to select": "No choices to select"
|
"No choices to select": "No choices to select"
|
||||||
|
},
|
||||||
|
"FormRenderer": {
|
||||||
|
"Reset": "Reset",
|
||||||
|
"Search": "Search",
|
||||||
|
"Select...": "Select...",
|
||||||
|
"Submit": "Submit"
|
||||||
|
},
|
||||||
|
"widgetTypesMap": {
|
||||||
|
"Calendar": "Calendar",
|
||||||
|
"Card": "Card",
|
||||||
|
"Card List": "Card List",
|
||||||
|
"Chart": "Chart",
|
||||||
|
"Custom": "Custom",
|
||||||
|
"Form": "Form",
|
||||||
|
"Table": "Table"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -250,7 +250,34 @@
|
|||||||
"API": "API",
|
"API": "API",
|
||||||
"Webhooks": "Ganchos Web",
|
"Webhooks": "Ganchos Web",
|
||||||
"Manage Webhooks": "Administrar los ganchos web",
|
"Manage Webhooks": "Administrar los ganchos web",
|
||||||
"API Console": "Consola de la API"
|
"API Console": "Consola de la API",
|
||||||
|
"API URL copied to clipboard": "URL de API copiada en el portapapeles",
|
||||||
|
"API documentation.": "Documentación de la API.",
|
||||||
|
"Base doc URL: {{docApiUrl}}": "URL del doc base: {{docApiUrl}}",
|
||||||
|
"Currency": "Moneda",
|
||||||
|
"Data Engine": "Motor de datos",
|
||||||
|
"Formula times": "Tiempo de fórmula",
|
||||||
|
"Hard reset of data engine": "Reinicio completo del motor de datos",
|
||||||
|
"ID for API use": "ID para uso de API",
|
||||||
|
"Locale": "Configuración regional",
|
||||||
|
"API console": "Consola de la API",
|
||||||
|
"Coming soon": "Próximamente",
|
||||||
|
"Copy to clipboard": "Copiar al portapapeles",
|
||||||
|
"Default for DateTime columns": "Predeterminado para las columnas DateTime",
|
||||||
|
"Document ID": "ID del documento",
|
||||||
|
"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "ID de documento para usar cuando la API REST solicite {{docId}}. Véase {{apiURL}}",
|
||||||
|
"Find slow formulas": "Encontrar fórmulas lentas",
|
||||||
|
"For currency columns": "Para columnas de moneda",
|
||||||
|
"For number and date formats": "Para formatos de número y fecha",
|
||||||
|
"Manage webhooks": "Administrar los ganchos web",
|
||||||
|
"Python version used": "Versión Python utilizada",
|
||||||
|
"Time Zone": "Huso horario",
|
||||||
|
"Try API calls from the browser": "Pruebe las llamadas API desde el navegador",
|
||||||
|
"Reload": "Recargar",
|
||||||
|
"python2 (legacy)": "python2 (legado)",
|
||||||
|
"Notify other services on doc changes": "Notificar a otros servicios los cambios de documentos",
|
||||||
|
"Python": "Python",
|
||||||
|
"python3 (recommended)": "python3 (recomendado)"
|
||||||
},
|
},
|
||||||
"DuplicateTable": {
|
"DuplicateTable": {
|
||||||
"Copy all data in addition to the table structure.": "Copiar todos los datos además de la estructura de la tabla.",
|
"Copy all data in addition to the table structure.": "Copiar todos los datos además de la estructura de la tabla.",
|
||||||
@ -503,7 +530,8 @@
|
|||||||
},
|
},
|
||||||
"OnBoardingPopups": {
|
"OnBoardingPopups": {
|
||||||
"Finish": "Finalizar",
|
"Finish": "Finalizar",
|
||||||
"Next": "Siguiente"
|
"Next": "Siguiente",
|
||||||
|
"Previous": "Anterior"
|
||||||
},
|
},
|
||||||
"OpenVideoTour": {
|
"OpenVideoTour": {
|
||||||
"Grist Video Tour": "Recorrido en video de Grist",
|
"Grist Video Tour": "Recorrido en video de Grist",
|
||||||
@ -986,7 +1014,8 @@
|
|||||||
"Don't ask again.": "No preguntes de nuevo.",
|
"Don't ask again.": "No preguntes de nuevo.",
|
||||||
"Don't show again.": "No vuelvas a mostrarlo.",
|
"Don't show again.": "No vuelvas a mostrarlo.",
|
||||||
"Don't show again": "No volver a mostrar",
|
"Don't show again": "No volver a mostrar",
|
||||||
"Are you sure you want to delete these records?": "¿Seguro que quieres borrar estos registros?"
|
"Are you sure you want to delete these records?": "¿Seguro que quieres borrar estos registros?",
|
||||||
|
"TIP": "TIP"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"Duplicate Page": "Duplicar página",
|
"Duplicate Page": "Duplicar página",
|
||||||
@ -1597,5 +1626,20 @@
|
|||||||
},
|
},
|
||||||
"DropdownConditionEditor": {
|
"DropdownConditionEditor": {
|
||||||
"Enter condition.": "Introduzca la condición."
|
"Enter condition.": "Introduzca la condición."
|
||||||
|
},
|
||||||
|
"FormRenderer": {
|
||||||
|
"Select...": "Seleccione...",
|
||||||
|
"Reset": "Restablecer",
|
||||||
|
"Search": "Búsqueda",
|
||||||
|
"Submit": "Enviar"
|
||||||
|
},
|
||||||
|
"widgetTypesMap": {
|
||||||
|
"Calendar": "Calendario",
|
||||||
|
"Card": "Tarjeta",
|
||||||
|
"Card List": "Lista de tarjetas",
|
||||||
|
"Chart": "Gráfico",
|
||||||
|
"Custom": "Personalizado",
|
||||||
|
"Form": "Formulario",
|
||||||
|
"Table": "Tabla"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -296,7 +296,9 @@
|
|||||||
"Ok": "OK",
|
"Ok": "OK",
|
||||||
"Manage Webhooks": "Gérer les points d’ancrage Web",
|
"Manage Webhooks": "Gérer les points d’ancrage Web",
|
||||||
"Webhooks": "Points d’ancrage Web",
|
"Webhooks": "Points d’ancrage Web",
|
||||||
"API Console": "Console de l'API"
|
"API Console": "Console de l'API",
|
||||||
|
"Reload": "Recharger",
|
||||||
|
"Python": "Python"
|
||||||
},
|
},
|
||||||
"DocumentUsage": {
|
"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.",
|
"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.",
|
||||||
@ -495,7 +497,8 @@
|
|||||||
"Workspace will be moved to Trash.": "Le dossier va être mis à la corbeille.",
|
"Workspace will be moved to Trash.": "Le dossier va être mis à la corbeille.",
|
||||||
"Manage Users": "Gérer les utilisateurs",
|
"Manage Users": "Gérer les utilisateurs",
|
||||||
"Access Details": "Détails d'accès",
|
"Access Details": "Détails d'accès",
|
||||||
"Tutorial": "Tutoriel"
|
"Tutorial": "Tutoriel",
|
||||||
|
"Terms of service": "CGU"
|
||||||
},
|
},
|
||||||
"Importer": {
|
"Importer": {
|
||||||
"Update existing records": "Mettre à jour les enregistrements existants",
|
"Update existing records": "Mettre à jour les enregistrements existants",
|
||||||
@ -564,7 +567,8 @@
|
|||||||
},
|
},
|
||||||
"OnBoardingPopups": {
|
"OnBoardingPopups": {
|
||||||
"Finish": "Terminer",
|
"Finish": "Terminer",
|
||||||
"Next": "Suivant"
|
"Next": "Suivant",
|
||||||
|
"Previous": "Précédent"
|
||||||
},
|
},
|
||||||
"OpenVideoTour": {
|
"OpenVideoTour": {
|
||||||
"YouTube video player": "Lecteur vidéo YouTube",
|
"YouTube video player": "Lecteur vidéo YouTube",
|
||||||
@ -1146,7 +1150,9 @@
|
|||||||
"Learn more": "En savoir plus",
|
"Learn more": "En savoir plus",
|
||||||
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Créez des formulaires simples directement dans Grist et partagez-les en un clic avec notre nouveau widget. {{learnMoreButton}}",
|
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Créez des formulaires simples directement dans Grist et partagez-les en un clic avec notre nouveau widget. {{learnMoreButton}}",
|
||||||
"Forms are here!": "Les formulaires sont là !",
|
"Forms are here!": "Les formulaires sont là !",
|
||||||
"These rules are applied after all column rules have been processed, if applicable.": "Ces règles sont appliquées après le traitement de toutes les règles de la colonne, le cas échéant."
|
"These rules are applied after all column rules have been processed, if applicable.": "Ces règles sont appliquées après le traitement de toutes les règles de la colonne, le cas échéant.",
|
||||||
|
"Example: {{example}}": "Exemple : {{example}}",
|
||||||
|
"Filter displayed dropdown values with a condition.": "Filtrer les valeurs affichées dans la liste déroulante en fonction d'une condition."
|
||||||
},
|
},
|
||||||
"ColumnTitle": {
|
"ColumnTitle": {
|
||||||
"Add description": "Ajouter une description",
|
"Add description": "Ajouter une description",
|
||||||
@ -1473,7 +1479,24 @@
|
|||||||
"Admin Panel": "Panneau d'administration",
|
"Admin Panel": "Panneau d'administration",
|
||||||
"Sponsor": "Parrainage",
|
"Sponsor": "Parrainage",
|
||||||
"Support Grist": "Soutenir Grist",
|
"Support Grist": "Soutenir Grist",
|
||||||
"Version": "Version"
|
"Version": "Version",
|
||||||
|
"Check now": "Vérifier",
|
||||||
|
"Checking for updates...": "Vérifier les mises à jour...",
|
||||||
|
"Error": "Erreur",
|
||||||
|
"Error checking for updates": "Erreur lors de la vérification des mises à jour",
|
||||||
|
"Grist is up to date": "Grist est à jour",
|
||||||
|
"Last checked {{time}}": "Dernière vérification {{time}}",
|
||||||
|
"Learn more.": "En savoir plus.",
|
||||||
|
"No information available": "Aucune information disponible",
|
||||||
|
"OK": "OK",
|
||||||
|
"Security Settings": "Paramètres de sécurité",
|
||||||
|
"Grist releases are at ": "Les releases de Grist sont disponibles sur ",
|
||||||
|
"Newer version available": "Nouvelle version disponible",
|
||||||
|
"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.": "Grist permet d'utiliser des formules très puissantes, en utilisant Python. Nous recommandons de définir la variable d'environnement GRIST_SANDBOX_FLAVOR sur gvisor si votre machine le supporte (la plupart le supportent), afin d'exécuter des formules dans chaque document à l'intérieur d'une sandbox isolée des autres documents et du réseau.",
|
||||||
|
"Updates": "Mises à jour",
|
||||||
|
"unconfigured": "non configuré",
|
||||||
|
"unknown": "inconnu",
|
||||||
|
"Auto-check when this page loads": "Vérification automatique au chargement de cette page"
|
||||||
},
|
},
|
||||||
"Field": {
|
"Field": {
|
||||||
"No choices configured": "Aucun choix configuré",
|
"No choices configured": "Aucun choix configuré",
|
||||||
@ -1500,5 +1523,43 @@
|
|||||||
},
|
},
|
||||||
"Columns": {
|
"Columns": {
|
||||||
"Remove Column": "Supprimer la colonne"
|
"Remove Column": "Supprimer la colonne"
|
||||||
|
},
|
||||||
|
"ChoiceEditor": {
|
||||||
|
"Error in dropdown condition": "Erreur dans la condition de la liste déroulante",
|
||||||
|
"No choices to select": "Aucun choix à sélectionner",
|
||||||
|
"No choices matching condition": "Aucun choix correspondant à la condition"
|
||||||
|
},
|
||||||
|
"ChoiceListEditor": {
|
||||||
|
"Error in dropdown condition": "Erreur dans la condition de la liste déroulante",
|
||||||
|
"No choices to select": "Aucun choix à sélectionner",
|
||||||
|
"No choices matching condition": "Aucun choix correspondant à la condition"
|
||||||
|
},
|
||||||
|
"DropdownConditionConfig": {
|
||||||
|
"Invalid columns: {{colIds}}": "Colonnes invalides : {{colIds}}",
|
||||||
|
"Set dropdown condition": "Définir la condition de la liste déroulante",
|
||||||
|
"Dropdown Condition": "Condition de la liste déroulante"
|
||||||
|
},
|
||||||
|
"DropdownConditionEditor": {
|
||||||
|
"Enter condition.": "Saisir la condition."
|
||||||
|
},
|
||||||
|
"ReferenceUtils": {
|
||||||
|
"Error in dropdown condition": "Erreur dans la condition de la liste déroulant",
|
||||||
|
"No choices matching condition": "Aucun choix correspondant à la condition",
|
||||||
|
"No choices to select": "Aucun choix à sélectionner"
|
||||||
|
},
|
||||||
|
"FormRenderer": {
|
||||||
|
"Submit": "Soumettre",
|
||||||
|
"Select...": "Sélectionner...",
|
||||||
|
"Search": "Rechercher",
|
||||||
|
"Reset": "Réinitialiser"
|
||||||
|
},
|
||||||
|
"widgetTypesMap": {
|
||||||
|
"Table": "Table",
|
||||||
|
"Form": "Formulaire",
|
||||||
|
"Custom": "Personnalisée",
|
||||||
|
"Chart": "Graphique",
|
||||||
|
"Card List": "Liste de fiches",
|
||||||
|
"Card": "Fiche",
|
||||||
|
"Calendar": "Calendrier"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -699,7 +699,34 @@
|
|||||||
"Ok": "OK",
|
"Ok": "OK",
|
||||||
"Manage Webhooks": "Gestisci web hook",
|
"Manage Webhooks": "Gestisci web hook",
|
||||||
"Webhooks": "Web hook",
|
"Webhooks": "Web hook",
|
||||||
"API Console": "Tavola delle API"
|
"API Console": "Tavola delle API",
|
||||||
|
"API console": "Console Api",
|
||||||
|
"Coming soon": "In arrivo",
|
||||||
|
"Copy to clipboard": "Copia negli appunti",
|
||||||
|
"Data Engine": "Motore dati",
|
||||||
|
"Default for DateTime columns": "Default per le colonne Data/Ora",
|
||||||
|
"Document ID": "ID documento",
|
||||||
|
"Find slow formulas": "Trova le formule lente",
|
||||||
|
"For currency columns": "Per le colonne di valuta",
|
||||||
|
"For number and date formats": "Per i formati di numeri e date",
|
||||||
|
"Formula times": "Tempi delle formule",
|
||||||
|
"Hard reset of data engine": "Reset forzato del motore dati",
|
||||||
|
"ID for API use": "ID per l'uso con le Api",
|
||||||
|
"Locale": "Locale",
|
||||||
|
"Manage webhooks": "Gestisci i webhooks",
|
||||||
|
"Notify other services on doc changes": "Notifica altri servizi dei cambiamenti nel documento",
|
||||||
|
"Python": "Python",
|
||||||
|
"Reload": "Ricarica",
|
||||||
|
"Time Zone": "Fuso orario",
|
||||||
|
"python2 (legacy)": "Python 2 (superato)",
|
||||||
|
"python3 (recommended)": "Python 3 (raccomandato)",
|
||||||
|
"API URL copied to clipboard": "Url della Api copiata negli appunti",
|
||||||
|
"API documentation.": "Documentazione Api.",
|
||||||
|
"Base doc URL: {{docApiUrl}}": "Url documento base: {{docApiUrl}}",
|
||||||
|
"Currency": "Valuta",
|
||||||
|
"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "ID Documento da usare quando le Api REST chiedono {{docId}}. Vedi {{apiURL}}",
|
||||||
|
"Python version used": "Versione di Python in uso",
|
||||||
|
"Try API calls from the browser": "Prova le chiamate Api nel browser"
|
||||||
},
|
},
|
||||||
"DocumentUsage": {
|
"DocumentUsage": {
|
||||||
"Data Size": "Dimensione dei dati",
|
"Data Size": "Dimensione dei dati",
|
||||||
@ -860,7 +887,8 @@
|
|||||||
},
|
},
|
||||||
"OnBoardingPopups": {
|
"OnBoardingPopups": {
|
||||||
"Finish": "Termina",
|
"Finish": "Termina",
|
||||||
"Next": "Prossimo"
|
"Next": "Prossimo",
|
||||||
|
"Previous": "Precedente"
|
||||||
},
|
},
|
||||||
"OpenVideoTour": {
|
"OpenVideoTour": {
|
||||||
"Grist Video Tour": "Video tour di Grist",
|
"Grist Video Tour": "Video tour di Grist",
|
||||||
@ -1041,7 +1069,8 @@
|
|||||||
"Dismiss": "Ignora",
|
"Dismiss": "Ignora",
|
||||||
"Don't ask again.": "Non chiedere più.",
|
"Don't ask again.": "Non chiedere più.",
|
||||||
"Don't show again.": "Non mostrare più.",
|
"Don't show again.": "Non mostrare più.",
|
||||||
"Don't show tips": "Non mostrare i suggerimenti"
|
"Don't show tips": "Non mostrare i suggerimenti",
|
||||||
|
"TIP": "TIP"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"Duplicate Page": "Duplica pagina",
|
"Duplicate Page": "Duplica pagina",
|
||||||
@ -1145,7 +1174,9 @@
|
|||||||
"These rules are applied after all column rules have been processed, if applicable.": "Queste regole sono applicate dopo che tutte le regole delle colonne sono state applicate, se possibile.",
|
"These rules are applied after all column rules have been processed, if applicable.": "Queste regole sono applicate dopo che tutte le regole delle colonne sono state applicate, se possibile.",
|
||||||
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Costruisci del semplici moduli e condividili rapidamente con il nostro nuovo widget. {{learnMoreButton}}",
|
"Build simple forms right in Grist and share in a click with our new widget. {{learnMoreButton}}": "Costruisci del semplici moduli e condividili rapidamente con il nostro nuovo widget. {{learnMoreButton}}",
|
||||||
"Forms are here!": "Sono arrivati i moduli!",
|
"Forms are here!": "Sono arrivati i moduli!",
|
||||||
"Learn more": "Approfondisci"
|
"Learn more": "Approfondisci",
|
||||||
|
"Example: {{example}}": "Esempio: {{example}}",
|
||||||
|
"Filter displayed dropdown values with a condition.": "Il filtro mostrava i valori nella tendina con una condizione."
|
||||||
},
|
},
|
||||||
"DescriptionConfig": {
|
"DescriptionConfig": {
|
||||||
"DESCRIPTION": "DESCRIZIONE"
|
"DESCRIPTION": "DESCRIZIONE"
|
||||||
@ -1416,7 +1447,26 @@
|
|||||||
"Sponsor": "Sponsor",
|
"Sponsor": "Sponsor",
|
||||||
"Current": "Attuale",
|
"Current": "Attuale",
|
||||||
"Telemetry": "Telemetria",
|
"Telemetry": "Telemetria",
|
||||||
"Version": "Versione"
|
"Version": "Versione",
|
||||||
|
"Auto-check when this page loads": "Controlla automaticamente quando questa pagina è caricata",
|
||||||
|
"Grist is up to date": "Grist è aggiornato",
|
||||||
|
"Error checking for updates": "Errore nel controllo degli aggiornamenti",
|
||||||
|
"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.": "Grist supporta formule molto potenti, grazie a Python. Raccomandiamo di impostare la variabile d'ambiente GRIST_SANDBOX_FLAVOR a gvisor se il vostro hardware lo permette (in genere sì), così che le formule di un documento agiscano in una sandbox isolata dagli altri documenti e dalla rete.",
|
||||||
|
"Last checked {{time}}": "Ultimo controllo {{time}}",
|
||||||
|
"Learn more.": "Per saperne di più.",
|
||||||
|
"Newer version available": "Disponibile una versione più recente",
|
||||||
|
"No information available": "Nessuna informazione disponibile",
|
||||||
|
"OK": "OK",
|
||||||
|
"Sandboxing": "Uso della sandbox",
|
||||||
|
"Security Settings": "Impostazioni di sicurezza",
|
||||||
|
"Updates": "Aggiornamenti",
|
||||||
|
"unconfigured": "non configurato",
|
||||||
|
"unknown": "sconosciuto",
|
||||||
|
"Error": "Errore",
|
||||||
|
"Check now": "Controlla adesso",
|
||||||
|
"Checking for updates...": "Controllo gli aggiornamenti...",
|
||||||
|
"Grist releases are at ": "Le release di Grist sono a ",
|
||||||
|
"Sandbox settings for data engine": "Impostazione della sandbox per il motore dati"
|
||||||
},
|
},
|
||||||
"WelcomeCoachingCall": {
|
"WelcomeCoachingCall": {
|
||||||
"Maybe Later": "Forse più tardi",
|
"Maybe Later": "Forse più tardi",
|
||||||
@ -1499,5 +1549,43 @@
|
|||||||
},
|
},
|
||||||
"FormErrorPage": {
|
"FormErrorPage": {
|
||||||
"Error": "Errore"
|
"Error": "Errore"
|
||||||
|
},
|
||||||
|
"ChoiceEditor": {
|
||||||
|
"Error in dropdown condition": "Errore nella condizione della tendina",
|
||||||
|
"No choices matching condition": "Nessuna scelta soddisfa la condizione",
|
||||||
|
"No choices to select": "Nessuna scelta da selezionare"
|
||||||
|
},
|
||||||
|
"ReferenceUtils": {
|
||||||
|
"No choices to select": "Nessuna scelta da selezionare",
|
||||||
|
"Error in dropdown condition": "Errore nella condizione della tendina",
|
||||||
|
"No choices matching condition": "Nessuna scelta soddisfa la condizione"
|
||||||
|
},
|
||||||
|
"DropdownConditionConfig": {
|
||||||
|
"Set dropdown condition": "Imposta la condizione della tendina",
|
||||||
|
"Dropdown Condition": "Condizione della tendina",
|
||||||
|
"Invalid columns: {{colIds}}": "Colonne non valide: {{colIds}}"
|
||||||
|
},
|
||||||
|
"DropdownConditionEditor": {
|
||||||
|
"Enter condition.": "Inserisci la condizione."
|
||||||
|
},
|
||||||
|
"ChoiceListEditor": {
|
||||||
|
"Error in dropdown condition": "Errore nella condizione della tendina",
|
||||||
|
"No choices matching condition": "Nessuna scelta soddisfa la condizione",
|
||||||
|
"No choices to select": "Nessuna scelta da selezionare"
|
||||||
|
},
|
||||||
|
"FormRenderer": {
|
||||||
|
"Reset": "Reset",
|
||||||
|
"Search": "Cerca",
|
||||||
|
"Select...": "Seleziona...",
|
||||||
|
"Submit": "Invia"
|
||||||
|
},
|
||||||
|
"widgetTypesMap": {
|
||||||
|
"Card": "Scheda",
|
||||||
|
"Table": "Tabella",
|
||||||
|
"Calendar": "Calendario",
|
||||||
|
"Card List": "Lista di schede",
|
||||||
|
"Chart": "Grafico",
|
||||||
|
"Custom": "Personalizzato",
|
||||||
|
"Form": "Modulo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -308,7 +308,34 @@
|
|||||||
"API": "API",
|
"API": "API",
|
||||||
"Manage Webhooks": "Gerenciar ganchos web",
|
"Manage Webhooks": "Gerenciar ganchos web",
|
||||||
"Webhooks": "Ganchos Web",
|
"Webhooks": "Ganchos Web",
|
||||||
"API Console": "Consola API"
|
"API Console": "Consola API",
|
||||||
|
"API documentation.": "Documentação de API.",
|
||||||
|
"Currency": "Moeda",
|
||||||
|
"Data Engine": "Motor de dados",
|
||||||
|
"Find slow formulas": "Encontrar fórmulas lentas",
|
||||||
|
"For currency columns": "Para colunas de moeda",
|
||||||
|
"Hard reset of data engine": "Reinicialização total do motor de dados",
|
||||||
|
"Manage webhooks": "Gerenciar webhooks",
|
||||||
|
"Python": "Python",
|
||||||
|
"Python version used": "Versão Python usada",
|
||||||
|
"Try API calls from the browser": "Experimente chamadas de API do navegador",
|
||||||
|
"python2 (legacy)": "python2 (legado)",
|
||||||
|
"API URL copied to clipboard": "URL da API copiado para a área de transferência",
|
||||||
|
"Coming soon": "Em breve",
|
||||||
|
"API console": "Consola API",
|
||||||
|
"Base doc URL: {{docApiUrl}}": "URL do documento base: {{docApiUrl}}",
|
||||||
|
"Copy to clipboard": "Copiar para a área de transferência",
|
||||||
|
"Default for DateTime columns": "Padrão para colunas DataHorário",
|
||||||
|
"Document ID": "ID do documento",
|
||||||
|
"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "ID do documento a ser usado sempre que a API REST fizer uma chamada para {{docId}}. Veja {{apiURL}}",
|
||||||
|
"For number and date formats": "Para formatos de número e data",
|
||||||
|
"Formula times": "Tempos de fórmula",
|
||||||
|
"Locale": "Localização",
|
||||||
|
"ID for API use": "ID para uso da API",
|
||||||
|
"Notify other services on doc changes": "Notifique outros serviços em alterações doc",
|
||||||
|
"Reload": "Recarregar",
|
||||||
|
"Time Zone": "Fuso horário",
|
||||||
|
"python3 (recommended)": "python3 (recomendado)"
|
||||||
},
|
},
|
||||||
"DocumentUsage": {
|
"DocumentUsage": {
|
||||||
"Attachments Size": "Tamanho dos Anexos",
|
"Attachments Size": "Tamanho dos Anexos",
|
||||||
@ -607,7 +634,8 @@
|
|||||||
},
|
},
|
||||||
"OnBoardingPopups": {
|
"OnBoardingPopups": {
|
||||||
"Finish": "Terminar",
|
"Finish": "Terminar",
|
||||||
"Next": "Próximo"
|
"Next": "Próximo",
|
||||||
|
"Previous": "Anterior"
|
||||||
},
|
},
|
||||||
"OpenVideoTour": {
|
"OpenVideoTour": {
|
||||||
"Grist Video Tour": "Tour de Vídeo Grist",
|
"Grist Video Tour": "Tour de Vídeo Grist",
|
||||||
@ -981,7 +1009,8 @@
|
|||||||
"Got it": "Entendido",
|
"Got it": "Entendido",
|
||||||
"Don't show again": "Não mostrar novamente",
|
"Don't show again": "Não mostrar novamente",
|
||||||
"Don't show again.": "Não mostrar novamente.",
|
"Don't show again.": "Não mostrar novamente.",
|
||||||
"Don't show tips": "Não mostrar dicas"
|
"Don't show tips": "Não mostrar dicas",
|
||||||
|
"TIP": "DICA"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"Duplicate Page": "Duplicar a Página",
|
"Duplicate Page": "Duplicar a Página",
|
||||||
@ -1607,5 +1636,20 @@
|
|||||||
"Error in dropdown condition": "Erro na condição do menu suspenso",
|
"Error in dropdown condition": "Erro na condição do menu suspenso",
|
||||||
"No choices matching condition": "Nenhuma opção que corresponda à condição",
|
"No choices matching condition": "Nenhuma opção que corresponda à condição",
|
||||||
"No choices to select": "Não há opções para selecionar"
|
"No choices to select": "Não há opções para selecionar"
|
||||||
|
},
|
||||||
|
"FormRenderer": {
|
||||||
|
"Reset": "Redefinir",
|
||||||
|
"Search": "Pesquisar",
|
||||||
|
"Select...": "Selecionar...",
|
||||||
|
"Submit": "Enviar"
|
||||||
|
},
|
||||||
|
"widgetTypesMap": {
|
||||||
|
"Calendar": "Calendário",
|
||||||
|
"Card": "Cartão",
|
||||||
|
"Card List": "Lista de cartões",
|
||||||
|
"Chart": "Gráfico",
|
||||||
|
"Custom": "Personalizado",
|
||||||
|
"Table": "Tabela",
|
||||||
|
"Form": "Formulário"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1315,7 +1315,7 @@
|
|||||||
"Opt out of Telemetry": "Отказаться от телеметрии",
|
"Opt out of Telemetry": "Отказаться от телеметрии",
|
||||||
"Sponsor Grist Labs on GitHub": "Спонсор Grist Labs на GitHub",
|
"Sponsor Grist Labs on GitHub": "Спонсор Grist Labs на GitHub",
|
||||||
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "В этом экземпляре включена телеметрия. Только администратор сайта может изменить это.",
|
"This instance is opted in to telemetry. Only the site administrator has permission to change this.": "В этом экземпляре включена телеметрия. Только администратор сайта может изменить это.",
|
||||||
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Мы собираем только статистику использования, как описано в нашей {{link}}, никогда содержимое документов.",
|
"We only collect usage statistics, as detailed in our {{link}}, never document contents.": "Мы собираем только статистику использования, как описано в {{link}} и никогда не собираем содержимое документов.",
|
||||||
"Sponsor": "Спонсор"
|
"Sponsor": "Спонсор"
|
||||||
},
|
},
|
||||||
"buildViewSectionDom": {
|
"buildViewSectionDom": {
|
||||||
|
@ -497,7 +497,22 @@
|
|||||||
"Manage Webhooks": "Upravljanje spletnih kljuk",
|
"Manage Webhooks": "Upravljanje spletnih kljuk",
|
||||||
"Webhooks": "Spletne kljuke",
|
"Webhooks": "Spletne kljuke",
|
||||||
"Engine (experimental {{span}} change at own risk):": "Pogon (eksperimentalno {{span}} spreminjanje na lastno odgovornost):",
|
"Engine (experimental {{span}} change at own risk):": "Pogon (eksperimentalno {{span}} spreminjanje na lastno odgovornost):",
|
||||||
"API Console": "API Konzola"
|
"API Console": "API Konzola",
|
||||||
|
"API console": "API konzola",
|
||||||
|
"API documentation.": "API dokumentacija.",
|
||||||
|
"Base doc URL: {{docApiUrl}}": "URL osnovnega dokumenta: {{docApiUrl}}",
|
||||||
|
"Coming soon": "Prihaja kmalu",
|
||||||
|
"Copy to clipboard": "Kopiraj v odložišče",
|
||||||
|
"Currency": "Valuta",
|
||||||
|
"Data Engine": "Podatkovna mašina",
|
||||||
|
"Document ID": "ID dokumenta",
|
||||||
|
"Document ID to use whenever the REST API calls for {{docId}}. See {{apiURL}}": "ID dokumenta, ki se uporabi vsakič, ko REST API pokliče {{docId}}. Oglej si {{apiURL}}",
|
||||||
|
"Find slow formulas": "Poišči počasne formule",
|
||||||
|
"For currency columns": "Za valutne stolpce",
|
||||||
|
"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"
|
||||||
},
|
},
|
||||||
"GridOptions": {
|
"GridOptions": {
|
||||||
"Horizontal Gridlines": "Vodoravne linije",
|
"Horizontal Gridlines": "Vodoravne linije",
|
||||||
|
37
test/nbrowser/TermsOfService.ts
Normal file
37
test/nbrowser/TermsOfService.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { assert, driver } from 'mocha-webdriver';
|
||||||
|
import * as gu from 'test/nbrowser/gristUtils';
|
||||||
|
import { server, setupTestSuite } from 'test/nbrowser/testUtils';
|
||||||
|
import { EnvironmentSnapshot } from 'test/server/testUtils';
|
||||||
|
|
||||||
|
describe('Terms of service link', function () {
|
||||||
|
this.timeout(20000);
|
||||||
|
setupTestSuite({samples: true});
|
||||||
|
|
||||||
|
let session: gu.Session;
|
||||||
|
let oldEnv: EnvironmentSnapshot;
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
oldEnv = new EnvironmentSnapshot();
|
||||||
|
session = await gu.session().teamSite.login();
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async function () {
|
||||||
|
oldEnv.restore();
|
||||||
|
await server.restart();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is visible in home menu', async function () {
|
||||||
|
process.env.GRIST_TERMS_OF_SERVICE_URL = 'https://example.com/tos';
|
||||||
|
await server.restart();
|
||||||
|
await session.loadDocMenu('/');
|
||||||
|
assert.isTrue(await driver.find('.test-dm-tos').isDisplayed());
|
||||||
|
assert.equal(await driver.find('.test-dm-tos').getAttribute('href'), 'https://example.com/tos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not visible when environment variable is not set', async function () {
|
||||||
|
delete process.env.GRIST_TERMS_OF_SERVICE_URL;
|
||||||
|
await server.restart();
|
||||||
|
await session.loadDocMenu('/');
|
||||||
|
assert.isFalse(await driver.find('.test-dm-tos').isPresent());
|
||||||
|
});
|
||||||
|
});
|
@ -34,7 +34,7 @@ import {serveSomething, Serving} from 'test/server/customUtil';
|
|||||||
import {prepareDatabase} from 'test/server/lib/helpers/PrepareDatabase';
|
import {prepareDatabase} from 'test/server/lib/helpers/PrepareDatabase';
|
||||||
import {prepareFilesystemDirectoryForTests} from 'test/server/lib/helpers/PrepareFilesystemDirectoryForTests';
|
import {prepareFilesystemDirectoryForTests} from 'test/server/lib/helpers/PrepareFilesystemDirectoryForTests';
|
||||||
import {signal} from 'test/server/lib/helpers/Signal';
|
import {signal} from 'test/server/lib/helpers/Signal';
|
||||||
import {TestServer} from 'test/server/lib/helpers/TestServer';
|
import {TestServer, TestServerReverseProxy} from 'test/server/lib/helpers/TestServer';
|
||||||
import * as testUtils from 'test/server/testUtils';
|
import * as testUtils from 'test/server/testUtils';
|
||||||
import {waitForIt} from 'test/server/wait';
|
import {waitForIt} from 'test/server/wait';
|
||||||
import defaultsDeep = require('lodash/defaultsDeep');
|
import defaultsDeep = require('lodash/defaultsDeep');
|
||||||
@ -42,12 +42,6 @@ import pick = require('lodash/pick');
|
|||||||
import { getDatabase } from 'test/testUtils';
|
import { getDatabase } from 'test/testUtils';
|
||||||
import {testDailyApiLimitFeatures} from 'test/gen-server/seed';
|
import {testDailyApiLimitFeatures} from 'test/gen-server/seed';
|
||||||
|
|
||||||
const chimpy = configForUser('Chimpy');
|
|
||||||
const kiwi = configForUser('Kiwi');
|
|
||||||
const charon = configForUser('Charon');
|
|
||||||
const nobody = configForUser('Anonymous');
|
|
||||||
const support = configForUser('support');
|
|
||||||
|
|
||||||
// some doc ids
|
// some doc ids
|
||||||
const docIds: { [name: string]: string } = {
|
const docIds: { [name: string]: string } = {
|
||||||
ApiDataRecordsTest: 'sampledocid_7',
|
ApiDataRecordsTest: 'sampledocid_7',
|
||||||
@ -68,6 +62,18 @@ let hasHomeApi: boolean;
|
|||||||
let home: TestServer;
|
let home: TestServer;
|
||||||
let docs: TestServer;
|
let docs: TestServer;
|
||||||
let userApi: UserAPIImpl;
|
let userApi: UserAPIImpl;
|
||||||
|
let extraHeadersForConfig = {};
|
||||||
|
|
||||||
|
function makeConfig(username: string): AxiosRequestConfig {
|
||||||
|
const originalConfig = configForUser(username);
|
||||||
|
return {
|
||||||
|
...originalConfig,
|
||||||
|
headers: {
|
||||||
|
...originalConfig.headers,
|
||||||
|
...extraHeadersForConfig
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('DocApi', function () {
|
describe('DocApi', function () {
|
||||||
this.timeout(30000);
|
this.timeout(30000);
|
||||||
@ -77,12 +83,7 @@ describe('DocApi', function () {
|
|||||||
before(async function () {
|
before(async function () {
|
||||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||||
|
|
||||||
// Clear redis test database if redis is in use.
|
await flushAllRedis();
|
||||||
if (process.env.TEST_REDIS_URL) {
|
|
||||||
const cli = createClient(process.env.TEST_REDIS_URL);
|
|
||||||
await cli.flushdbAsync();
|
|
||||||
await cli.quitAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the tmp dir removing any previous one
|
// Create the tmp dir removing any previous one
|
||||||
await prepareFilesystemDirectoryForTests(tmpDir);
|
await prepareFilesystemDirectoryForTests(tmpDir);
|
||||||
@ -136,6 +137,7 @@ describe('DocApi', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow anonymous users to create new docs', async () => {
|
it('should not allow anonymous users to create new docs', async () => {
|
||||||
|
const nobody = makeConfig('Anonymous');
|
||||||
const resp = await axios.post(`${serverUrl}/api/docs`, null, nobody);
|
const resp = await axios.post(`${serverUrl}/api/docs`, null, nobody);
|
||||||
assert.equal(resp.status, 403);
|
assert.equal(resp.status, 403);
|
||||||
});
|
});
|
||||||
@ -158,6 +160,95 @@ describe('DocApi', function () {
|
|||||||
testDocApi();
|
testDocApi();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('behind a reverse-proxy', function () {
|
||||||
|
async function setupServersWithProxy(suitename: string, overrideEnvConf?: NodeJS.ProcessEnv) {
|
||||||
|
const proxy = new TestServerReverseProxy();
|
||||||
|
const additionalEnvConfiguration = {
|
||||||
|
ALLOWED_WEBHOOK_DOMAINS: `example.com,localhost:${webhooksTestPort}`,
|
||||||
|
GRIST_DATA_DIR: dataDir,
|
||||||
|
APP_HOME_URL: await proxy.getServerUrl(),
|
||||||
|
GRIST_ORG_IN_PATH: 'true',
|
||||||
|
GRIST_SINGLE_PORT: '0',
|
||||||
|
...overrideEnvConf
|
||||||
|
};
|
||||||
|
const home = await TestServer.startServer('home', tmpDir, suitename, additionalEnvConfiguration);
|
||||||
|
const docs = await TestServer.startServer(
|
||||||
|
'docs', tmpDir, suitename, additionalEnvConfiguration, home.serverUrl
|
||||||
|
);
|
||||||
|
proxy.requireFromOutsideHeader();
|
||||||
|
|
||||||
|
await proxy.start(home, docs);
|
||||||
|
|
||||||
|
homeUrl = serverUrl = await proxy.getServerUrl();
|
||||||
|
hasHomeApi = true;
|
||||||
|
extraHeadersForConfig = {
|
||||||
|
Origin: serverUrl,
|
||||||
|
...TestServerReverseProxy.FROM_OUTSIDE_HEADER,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {proxy, home, docs};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tearDown(proxy: TestServerReverseProxy, servers: TestServer[]) {
|
||||||
|
proxy.stop();
|
||||||
|
for (const server of servers) {
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
await flushAllRedis();
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxy: TestServerReverseProxy;
|
||||||
|
|
||||||
|
describe('should run usual DocApi test', function () {
|
||||||
|
setup('behind-proxy-testdocs', async () => {
|
||||||
|
({proxy, home, docs} = await setupServersWithProxy(suitename));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => tearDown(proxy, [home, docs]));
|
||||||
|
|
||||||
|
testDocApi();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function testCompareDocs(proxy: TestServerReverseProxy, home: TestServer) {
|
||||||
|
const chimpy = makeConfig('chimpy');
|
||||||
|
const userApiServerUrl = await proxy.getServerUrl();
|
||||||
|
const chimpyApi = home.makeUserApi(
|
||||||
|
ORG_NAME, 'chimpy', { serverUrl: userApiServerUrl, headers: chimpy.headers as Record<string, string> }
|
||||||
|
);
|
||||||
|
const ws1 = (await chimpyApi.getOrgWorkspaces('current'))[0].id;
|
||||||
|
const docId1 = await chimpyApi.newDoc({name: 'testdoc1'}, ws1);
|
||||||
|
const docId2 = await chimpyApi.newDoc({name: 'testdoc2'}, ws1);
|
||||||
|
const doc1 = chimpyApi.getDocAPI(docId1);
|
||||||
|
|
||||||
|
return doc1.compareDoc(docId2);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('with APP_HOME_INTERNAL_URL', function () {
|
||||||
|
setup('behind-proxy-with-apphomeinternalurl', async () => {
|
||||||
|
// APP_HOME_INTERNAL_URL will be set by TestServer.
|
||||||
|
({proxy, home, docs} = await setupServersWithProxy(suitename));
|
||||||
|
});
|
||||||
|
after(() => tearDown(proxy, [home, docs]));
|
||||||
|
|
||||||
|
it('should succeed to compare docs', async function () {
|
||||||
|
const res = await testCompareDocs(proxy, home);
|
||||||
|
assert.exists(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('without APP_HOME_INTERNAL_URL', function () {
|
||||||
|
setup('behind-proxy-without-apphomeinternalurl', async () => {
|
||||||
|
({proxy, home, docs} = await setupServersWithProxy(suitename, {APP_HOME_INTERNAL_URL: ''}));
|
||||||
|
});
|
||||||
|
after(() => tearDown(proxy, [home, docs]));
|
||||||
|
|
||||||
|
it('should succeed to compare docs', async function () {
|
||||||
|
const promise = testCompareDocs(proxy, home);
|
||||||
|
await assert.isRejected(promise, /TestServerReverseProxy: called public URL/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("should work directly with a docworker", async () => {
|
describe("should work directly with a docworker", async () => {
|
||||||
setup('docs', async () => {
|
setup('docs', async () => {
|
||||||
const additionalEnvConfiguration = {
|
const additionalEnvConfiguration = {
|
||||||
@ -233,6 +324,17 @@ describe('DocApi', function () {
|
|||||||
|
|
||||||
// Contains the tests. This is where you want to add more test.
|
// Contains the tests. This is where you want to add more test.
|
||||||
function testDocApi() {
|
function testDocApi() {
|
||||||
|
let chimpy: AxiosRequestConfig, kiwi: AxiosRequestConfig,
|
||||||
|
charon: AxiosRequestConfig, nobody: AxiosRequestConfig, support: AxiosRequestConfig;
|
||||||
|
|
||||||
|
before(function () {
|
||||||
|
chimpy = makeConfig('Chimpy');
|
||||||
|
kiwi = makeConfig('Kiwi');
|
||||||
|
charon = makeConfig('Charon');
|
||||||
|
nobody = makeConfig('Anonymous');
|
||||||
|
support = makeConfig('support');
|
||||||
|
});
|
||||||
|
|
||||||
async function generateDocAndUrl(docName: string = "Dummy") {
|
async function generateDocAndUrl(docName: string = "Dummy") {
|
||||||
const wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id;
|
const wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id;
|
||||||
const docId = await userApi.newDoc({name: docName}, wid);
|
const docId = await userApi.newDoc({name: docName}, wid);
|
||||||
@ -1341,7 +1443,7 @@ function testDocApi() {
|
|||||||
it(`GET /docs/{did}/tables/{tid}/data supports sorts and limits in ${mode}`, async function () {
|
it(`GET /docs/{did}/tables/{tid}/data supports sorts and limits in ${mode}`, async function () {
|
||||||
function makeQuery(sort: string[] | null, limit: number | null) {
|
function makeQuery(sort: string[] | null, limit: number | null) {
|
||||||
const url = new URL(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`);
|
const url = new URL(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`);
|
||||||
const config = configForUser('chimpy');
|
const config = makeConfig('chimpy');
|
||||||
if (mode === 'url') {
|
if (mode === 'url') {
|
||||||
if (sort) {
|
if (sort) {
|
||||||
url.searchParams.append('sort', sort.join(','));
|
url.searchParams.append('sort', sort.join(','));
|
||||||
@ -2615,6 +2717,18 @@ function testDocApi() {
|
|||||||
await worker1.copyDoc(docId, undefined, 'copy');
|
await worker1.copyDoc(docId, undefined, 'copy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /docs/{did} with sourceDocId copies a document", async function () {
|
||||||
|
const chimpyWs = await userApi.newWorkspace({name: "Chimpy's Workspace"}, ORG_NAME);
|
||||||
|
const resp = await axios.post(`${serverUrl}/api/docs`, {
|
||||||
|
sourceDocumentId: docIds.TestDoc,
|
||||||
|
documentName: 'copy of TestDoc',
|
||||||
|
asTemplate: false,
|
||||||
|
workspaceId: chimpyWs
|
||||||
|
}, chimpy);
|
||||||
|
assert.equal(resp.status, 200);
|
||||||
|
assert.isString(resp.data);
|
||||||
|
});
|
||||||
|
|
||||||
it("GET /docs/{did}/download/csv serves CSV-encoded document", async function () {
|
it("GET /docs/{did}/download/csv serves CSV-encoded document", async function () {
|
||||||
const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/download/csv?tableId=Table1`, chimpy);
|
const resp = await axios.get(`${serverUrl}/api/docs/${docIds.Timesheets}/download/csv?tableId=Table1`, chimpy);
|
||||||
assert.equal(resp.status, 200);
|
assert.equal(resp.status, 200);
|
||||||
@ -2801,7 +2915,7 @@ function testDocApi() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('POST /workspaces/{wid}/import handles empty filenames', async function () {
|
it('POST /workspaces/{wid}/import handles empty filenames', async function () {
|
||||||
if (!process.env.TEST_REDIS_URL) {
|
if (!process.env.TEST_REDIS_URL || docs.proxiedServer) {
|
||||||
this.skip();
|
this.skip();
|
||||||
}
|
}
|
||||||
const worker1 = await userApi.getWorkerAPI('import');
|
const worker1 = await userApi.getWorkerAPI('import');
|
||||||
@ -2809,7 +2923,7 @@ function testDocApi() {
|
|||||||
const fakeData1 = await testUtils.readFixtureDoc('Hello.grist');
|
const fakeData1 = await testUtils.readFixtureDoc('Hello.grist');
|
||||||
const uploadId1 = await worker1.upload(fakeData1, '.grist');
|
const uploadId1 = await worker1.upload(fakeData1, '.grist');
|
||||||
const resp = await axios.post(`${worker1.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1},
|
const resp = await axios.post(`${worker1.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1},
|
||||||
configForUser('Chimpy'));
|
makeConfig('Chimpy'));
|
||||||
assert.equal(resp.status, 200);
|
assert.equal(resp.status, 200);
|
||||||
assert.equal(resp.data.title, 'Untitled upload');
|
assert.equal(resp.data.title, 'Untitled upload');
|
||||||
assert.equal(typeof resp.data.id, 'string');
|
assert.equal(typeof resp.data.id, 'string');
|
||||||
@ -2855,11 +2969,11 @@ function testDocApi() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("document is protected during upload-and-import sequence", async function () {
|
it("document is protected during upload-and-import sequence", async function () {
|
||||||
if (!process.env.TEST_REDIS_URL) {
|
if (!process.env.TEST_REDIS_URL || home.proxiedServer) {
|
||||||
this.skip();
|
this.skip();
|
||||||
}
|
}
|
||||||
// Prepare an API for a different user.
|
// Prepare an API for a different user.
|
||||||
const kiwiApi = new UserAPIImpl(`${home.serverUrl}/o/Fish`, {
|
const kiwiApi = new UserAPIImpl(`${homeUrl}/o/Fish`, {
|
||||||
headers: {Authorization: 'Bearer api_key_for_kiwi'},
|
headers: {Authorization: 'Bearer api_key_for_kiwi'},
|
||||||
fetch: fetch as any,
|
fetch: fetch as any,
|
||||||
newFormData: () => new FormData() as any,
|
newFormData: () => new FormData() as any,
|
||||||
@ -2875,18 +2989,18 @@ function testDocApi() {
|
|||||||
// Check that kiwi only has access to their own upload.
|
// Check that kiwi only has access to their own upload.
|
||||||
let wid = (await kiwiApi.getOrgWorkspaces('current')).find((w) => w.name === 'Big')!.id;
|
let wid = (await kiwiApi.getOrgWorkspaces('current')).find((w) => w.name === 'Big')!.id;
|
||||||
let resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1},
|
let resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1},
|
||||||
configForUser('Kiwi'));
|
makeConfig('Kiwi'));
|
||||||
assert.equal(resp.status, 403);
|
assert.equal(resp.status, 403);
|
||||||
assert.deepEqual(resp.data, {error: "access denied"});
|
assert.deepEqual(resp.data, {error: "access denied"});
|
||||||
|
|
||||||
resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, {uploadId: uploadId2},
|
resp = await axios.post(`${worker2.url}/api/workspaces/${wid}/import`, {uploadId: uploadId2},
|
||||||
configForUser('Kiwi'));
|
makeConfig('Kiwi'));
|
||||||
assert.equal(resp.status, 200);
|
assert.equal(resp.status, 200);
|
||||||
|
|
||||||
// Check that chimpy has access to their own upload.
|
// Check that chimpy has access to their own upload.
|
||||||
wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id;
|
wid = (await userApi.getOrgWorkspaces('current')).find((w) => w.name === 'Private')!.id;
|
||||||
resp = await axios.post(`${worker1.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1},
|
resp = await axios.post(`${worker1.url}/api/workspaces/${wid}/import`, {uploadId: uploadId1},
|
||||||
configForUser('Chimpy'));
|
makeConfig('Chimpy'));
|
||||||
assert.equal(resp.status, 200);
|
assert.equal(resp.status, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2963,10 +3077,11 @@ function testDocApi() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('filters urlIds by org', async function () {
|
it('filters urlIds by org', async function () {
|
||||||
|
if (home.proxiedServer) { this.skip(); }
|
||||||
// Make two documents with same urlId
|
// Make two documents with same urlId
|
||||||
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
|
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
|
||||||
const doc1 = await userApi.newDoc({name: 'testdoc1', urlId: 'urlid'}, ws1);
|
const doc1 = await userApi.newDoc({name: 'testdoc1', urlId: 'urlid'}, ws1);
|
||||||
const nasaApi = new UserAPIImpl(`${home.serverUrl}/o/nasa`, {
|
const nasaApi = new UserAPIImpl(`${homeUrl}/o/nasa`, {
|
||||||
headers: {Authorization: 'Bearer api_key_for_chimpy'},
|
headers: {Authorization: 'Bearer api_key_for_chimpy'},
|
||||||
fetch: fetch as any,
|
fetch: fetch as any,
|
||||||
newFormData: () => new FormData() as any,
|
newFormData: () => new FormData() as any,
|
||||||
@ -2995,9 +3110,10 @@ function testDocApi() {
|
|||||||
|
|
||||||
it('allows docId access to any document from merged org', async function () {
|
it('allows docId access to any document from merged org', async function () {
|
||||||
// Make two documents
|
// Make two documents
|
||||||
|
if (home.proxiedServer) { this.skip(); }
|
||||||
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
|
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
|
||||||
const doc1 = await userApi.newDoc({name: 'testdoc1'}, ws1);
|
const doc1 = await userApi.newDoc({name: 'testdoc1'}, ws1);
|
||||||
const nasaApi = new UserAPIImpl(`${home.serverUrl}/o/nasa`, {
|
const nasaApi = new UserAPIImpl(`${homeUrl}/o/nasa`, {
|
||||||
headers: {Authorization: 'Bearer api_key_for_chimpy'},
|
headers: {Authorization: 'Bearer api_key_for_chimpy'},
|
||||||
fetch: fetch as any,
|
fetch: fetch as any,
|
||||||
newFormData: () => new FormData() as any,
|
newFormData: () => new FormData() as any,
|
||||||
@ -3125,11 +3241,17 @@ function testDocApi() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("GET /docs/{did1}/compare/{did2} tracks changes between docs", async function () {
|
it("GET /docs/{did1}/compare/{did2} tracks changes between docs", async function () {
|
||||||
const ws1 = (await userApi.getOrgWorkspaces('current'))[0].id;
|
// Pass kiwi's headers as it contains both Authorization and Origin headers
|
||||||
const docId1 = await userApi.newDoc({name: 'testdoc1'}, ws1);
|
// if run behind a proxy, so we can ensure that the Origin header check is not made.
|
||||||
const docId2 = await userApi.newDoc({name: 'testdoc2'}, ws1);
|
const userApiServerUrl = docs.proxiedServer ? serverUrl : undefined;
|
||||||
const doc1 = userApi.getDocAPI(docId1);
|
const chimpyApi = home.makeUserApi(
|
||||||
const doc2 = userApi.getDocAPI(docId2);
|
ORG_NAME, 'chimpy', { serverUrl: userApiServerUrl, headers: chimpy.headers as Record<string, string> }
|
||||||
|
);
|
||||||
|
const ws1 = (await chimpyApi.getOrgWorkspaces('current'))[0].id;
|
||||||
|
const docId1 = await chimpyApi.newDoc({name: 'testdoc1'}, ws1);
|
||||||
|
const docId2 = await chimpyApi.newDoc({name: 'testdoc2'}, ws1);
|
||||||
|
const doc1 = chimpyApi.getDocAPI(docId1);
|
||||||
|
const doc2 = chimpyApi.getDocAPI(docId2);
|
||||||
|
|
||||||
// Stick some content in column A so it has a defined type
|
// Stick some content in column A so it has a defined type
|
||||||
// so diffs are smaller and simpler.
|
// so diffs are smaller and simpler.
|
||||||
@ -3327,6 +3449,9 @@ function testDocApi() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('doc worker endpoints ignore any /dw/.../ prefix', async function () {
|
it('doc worker endpoints ignore any /dw/.../ prefix', async function () {
|
||||||
|
if (docs.proxiedServer) {
|
||||||
|
this.skip();
|
||||||
|
}
|
||||||
const docWorkerUrl = docs.serverUrl;
|
const docWorkerUrl = docs.serverUrl;
|
||||||
let resp = await axios.get(`${docWorkerUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`, chimpy);
|
let resp = await axios.get(`${docWorkerUrl}/api/docs/${docIds.Timesheets}/tables/Table1/data`, chimpy);
|
||||||
assert.equal(resp.status, 200);
|
assert.equal(resp.status, 200);
|
||||||
@ -4970,18 +5095,26 @@ function testDocApi() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const chimpyConfig = configForUser("Chimpy");
|
const chimpyConfig = makeConfig("Chimpy");
|
||||||
const anonConfig = configForUser("Anonymous");
|
const anonConfig = makeConfig("Anonymous");
|
||||||
delete chimpyConfig.headers!["X-Requested-With"];
|
delete chimpyConfig.headers!["X-Requested-With"];
|
||||||
delete anonConfig.headers!["X-Requested-With"];
|
delete anonConfig.headers!["X-Requested-With"];
|
||||||
|
|
||||||
|
let allowedOrigin;
|
||||||
|
|
||||||
// Target a more realistic Host than "localhost:port"
|
// Target a more realistic Host than "localhost:port"
|
||||||
anonConfig.headers!.Host = chimpyConfig.headers!.Host = 'api.example.com';
|
// (if behind a proxy, we already benefit from a custom and realistic host).
|
||||||
|
if (!home.proxiedServer) {
|
||||||
|
anonConfig.headers!.Host = chimpyConfig.headers!.Host =
|
||||||
|
'api.example.com';
|
||||||
|
allowedOrigin = 'http://front.example.com';
|
||||||
|
} else {
|
||||||
|
allowedOrigin = serverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
const url = `${serverUrl}/api/docs/${docId}/tables/Table1/records`;
|
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';
|
const forbiddenOrigin = 'http://evil.com';
|
||||||
|
|
||||||
// Normal same origin requests
|
// Normal same origin requests
|
||||||
@ -5213,6 +5346,7 @@ function setup(name: string, cb: () => Promise<void>) {
|
|||||||
before(async function () {
|
before(async function () {
|
||||||
suitename = name;
|
suitename = name;
|
||||||
dataDir = path.join(tmpDir, `${suitename}-data`);
|
dataDir = path.join(tmpDir, `${suitename}-data`);
|
||||||
|
await flushAllRedis();
|
||||||
await fse.mkdirs(dataDir);
|
await fse.mkdirs(dataDir);
|
||||||
await setupDataDir(dataDir);
|
await setupDataDir(dataDir);
|
||||||
await cb();
|
await cb();
|
||||||
@ -5231,6 +5365,7 @@ function setup(name: string, cb: () => Promise<void>) {
|
|||||||
// stop all servers
|
// stop all servers
|
||||||
await home.stop();
|
await home.stop();
|
||||||
await docs.stop();
|
await docs.stop();
|
||||||
|
extraHeadersForConfig = {};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5259,3 +5394,12 @@ async function flushAuth() {
|
|||||||
await home.testingHooks.flushAuthorizerCache();
|
await home.testingHooks.flushAuthorizerCache();
|
||||||
await docs.testingHooks.flushAuthorizerCache();
|
await docs.testingHooks.flushAuthorizerCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flushAllRedis() {
|
||||||
|
// Clear redis test database if redis is in use.
|
||||||
|
if (process.env.TEST_REDIS_URL) {
|
||||||
|
const cli = createClient(process.env.TEST_REDIS_URL);
|
||||||
|
await cli.flushdbAsync();
|
||||||
|
await cli.quitAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -7,7 +7,6 @@ export class TestProxyServer {
|
|||||||
const server = new TestProxyServer();
|
const server = new TestProxyServer();
|
||||||
await server._prepare(portNumber);
|
await server._prepare(portNumber);
|
||||||
return server;
|
return server;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _proxyCallsCounter: number = 0;
|
private _proxyCallsCounter: number = 0;
|
||||||
@ -38,7 +37,6 @@ export class TestProxyServer {
|
|||||||
}
|
}
|
||||||
res.sendStatus(responseCode);
|
res.sendStatus(responseCode);
|
||||||
res.end();
|
res.end();
|
||||||
//next();
|
|
||||||
});
|
});
|
||||||
}, portNumber);
|
}, portNumber);
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,20 @@
|
|||||||
import {connectTestingHooks, TestingHooksClient} from "app/server/lib/TestingHooks";
|
import {connectTestingHooks, TestingHooksClient} from "app/server/lib/TestingHooks";
|
||||||
import {ChildProcess, execFileSync, spawn} from "child_process";
|
import {ChildProcess, execFileSync, spawn} from "child_process";
|
||||||
|
import * as http from "http";
|
||||||
import FormData from 'form-data';
|
import FormData from 'form-data';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import * as fse from "fs-extra";
|
import * as fse from "fs-extra";
|
||||||
import * as testUtils from "test/server/testUtils";
|
import * as testUtils from "test/server/testUtils";
|
||||||
import {UserAPIImpl} from "app/common/UserAPI";
|
import {UserAPIImpl} from "app/common/UserAPI";
|
||||||
import {exitPromise} from "app/server/lib/serverUtils";
|
import {exitPromise, getAvailablePort} from "app/server/lib/serverUtils";
|
||||||
import log from "app/server/lib/log";
|
import log from "app/server/lib/log";
|
||||||
import {delay} from "bluebird";
|
import {delay} from "bluebird";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import {Writable} from "stream";
|
import {Writable} from "stream";
|
||||||
|
import express from "express";
|
||||||
|
import { AddressInfo } from "net";
|
||||||
|
import { isAffirmative } from "app/common/gutil";
|
||||||
|
import httpProxy from 'http-proxy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This starts a server in a separate process.
|
* This starts a server in a separate process.
|
||||||
@ -24,18 +29,26 @@ export class TestServer {
|
|||||||
options: {output?: Writable} = {}, // Pipe server output to the given stream
|
options: {output?: Writable} = {}, // Pipe server output to the given stream
|
||||||
): Promise<TestServer> {
|
): Promise<TestServer> {
|
||||||
|
|
||||||
const server = new TestServer(serverTypes, tempDirectory, suitename);
|
const server = new this(serverTypes, tempDirectory, suitename);
|
||||||
await server.start(_homeUrl, customEnv, options);
|
await server.start(_homeUrl, customEnv, options);
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
public testingSocket: string;
|
public testingSocket: string;
|
||||||
public testingHooks: TestingHooksClient;
|
public testingHooks: TestingHooksClient;
|
||||||
public serverUrl: string;
|
|
||||||
public stopped = false;
|
public stopped = false;
|
||||||
|
public get serverUrl() {
|
||||||
|
if (this._proxiedServer) {
|
||||||
|
throw new Error('Direct access to this test server is disallowed');
|
||||||
|
}
|
||||||
|
return this._serverUrl;
|
||||||
|
}
|
||||||
|
public get proxiedServer() { return this._proxiedServer; }
|
||||||
|
|
||||||
|
private _serverUrl: string;
|
||||||
private _server: ChildProcess;
|
private _server: ChildProcess;
|
||||||
private _exitPromise: Promise<number | string>;
|
private _exitPromise: Promise<number | string>;
|
||||||
|
private _proxiedServer: boolean = false;
|
||||||
|
|
||||||
private readonly _defaultEnv;
|
private readonly _defaultEnv;
|
||||||
|
|
||||||
@ -44,9 +57,6 @@ export class TestServer {
|
|||||||
GRIST_INST_DIR: this.rootDir,
|
GRIST_INST_DIR: this.rootDir,
|
||||||
GRIST_DATA_DIR: path.join(this.rootDir, "data"),
|
GRIST_DATA_DIR: path.join(this.rootDir, "data"),
|
||||||
GRIST_SERVERS: this._serverTypes,
|
GRIST_SERVERS: this._serverTypes,
|
||||||
// with port '0' no need to hard code a port number (we can use testing hooks to find out what
|
|
||||||
// port server is listening on).
|
|
||||||
GRIST_PORT: '0',
|
|
||||||
GRIST_DISABLE_S3: 'true',
|
GRIST_DISABLE_S3: 'true',
|
||||||
REDIS_URL: process.env.TEST_REDIS_URL,
|
REDIS_URL: process.env.TEST_REDIS_URL,
|
||||||
GRIST_TRIGGER_WAIT_DELAY: '100',
|
GRIST_TRIGGER_WAIT_DELAY: '100',
|
||||||
@ -68,9 +78,16 @@ export class TestServer {
|
|||||||
// Unix socket paths typically can't be longer than this. Who knew. Make the error obvious.
|
// Unix socket paths typically can't be longer than this. Who knew. Make the error obvious.
|
||||||
throw new Error(`Path of testingSocket too long: ${this.testingSocket.length} (${this.testingSocket})`);
|
throw new Error(`Path of testingSocket too long: ${this.testingSocket.length} (${this.testingSocket})`);
|
||||||
}
|
}
|
||||||
const env = {
|
|
||||||
APP_HOME_URL: _homeUrl,
|
const port = await getAvailablePort();
|
||||||
|
this._serverUrl = `http://localhost:${port}`;
|
||||||
|
const homeUrl = _homeUrl ?? (this._serverTypes.includes('home') ? this._serverUrl : undefined);
|
||||||
|
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
APP_HOME_URL: homeUrl,
|
||||||
|
APP_HOME_INTERNAL_URL: homeUrl,
|
||||||
GRIST_TESTING_SOCKET: this.testingSocket,
|
GRIST_TESTING_SOCKET: this.testingSocket,
|
||||||
|
GRIST_PORT: String(port),
|
||||||
...this._defaultEnv,
|
...this._defaultEnv,
|
||||||
...customEnv
|
...customEnv
|
||||||
};
|
};
|
||||||
@ -98,7 +115,7 @@ export class TestServer {
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
||||||
await this._waitServerReady();
|
await this._waitServerReady();
|
||||||
log.info(`server ${this._serverTypes} up and listening on ${this.serverUrl}`);
|
log.info(`server ${this._serverTypes} up and listening on ${this._serverUrl}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {
|
public async stop() {
|
||||||
@ -125,11 +142,9 @@ export class TestServer {
|
|||||||
|
|
||||||
// create testing hooks and get own port
|
// create testing hooks and get own port
|
||||||
this.testingHooks = await connectTestingHooks(this.testingSocket);
|
this.testingHooks = await connectTestingHooks(this.testingSocket);
|
||||||
const port: number = await this.testingHooks.getOwnPort();
|
|
||||||
this.serverUrl = `http://localhost:${port}`;
|
|
||||||
|
|
||||||
// wait for check
|
// wait for check
|
||||||
return (await fetch(`${this.serverUrl}/status/hooks`, {timeout: 1000})).ok;
|
return (await fetch(`${this._serverUrl}/status/hooks`, {timeout: 1000})).ok;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn("Failed to initialize server", err);
|
log.warn("Failed to initialize server", err);
|
||||||
return false;
|
return false;
|
||||||
@ -142,14 +157,32 @@ export class TestServer {
|
|||||||
// Returns the promise for the ChildProcess's signal or exit code.
|
// Returns the promise for the ChildProcess's signal or exit code.
|
||||||
public getExitPromise(): Promise<string|number> { return this._exitPromise; }
|
public getExitPromise(): Promise<string|number> { return this._exitPromise; }
|
||||||
|
|
||||||
public makeUserApi(org: string, user: string = 'chimpy'): UserAPIImpl {
|
public makeUserApi(
|
||||||
return new UserAPIImpl(`${this.serverUrl}/o/${org}`, {
|
org: string,
|
||||||
headers: {Authorization: `Bearer api_key_for_${user}`},
|
user: string = 'chimpy',
|
||||||
|
{
|
||||||
|
headers = {Authorization: `Bearer api_key_for_${user}`},
|
||||||
|
serverUrl = this._serverUrl,
|
||||||
|
}: {
|
||||||
|
headers?: Record<string, string>
|
||||||
|
serverUrl?: string,
|
||||||
|
} = { headers: undefined, serverUrl: undefined },
|
||||||
|
): UserAPIImpl {
|
||||||
|
return new UserAPIImpl(`${serverUrl}/o/${org}`, {
|
||||||
|
headers,
|
||||||
fetch: fetch as unknown as typeof globalThis.fetch,
|
fetch: fetch as unknown as typeof globalThis.fetch,
|
||||||
newFormData: () => new FormData() as any,
|
newFormData: () => new FormData() as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assuming that the server is behind a reverse-proxy (like TestServerReverseProxy),
|
||||||
|
* disallow access to the serverUrl to prevent the tests to join the server directly.
|
||||||
|
*/
|
||||||
|
public disallowDirectAccess() {
|
||||||
|
this._proxiedServer = true;
|
||||||
|
}
|
||||||
|
|
||||||
private async _waitServerReady() {
|
private async _waitServerReady() {
|
||||||
// It's important to clear the timeout, because it can prevent node from exiting otherwise,
|
// It's important to clear the timeout, because it can prevent node from exiting otherwise,
|
||||||
// which is annoying when running only this test for debugging.
|
// which is annoying when running only this test for debugging.
|
||||||
@ -170,3 +203,103 @@ export class TestServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reverse-proxy for a home and a doc worker.
|
||||||
|
*
|
||||||
|
* The workers are then disallowed to be joined directly, the tests are assumed to
|
||||||
|
* pass through this reverse-proxy.
|
||||||
|
*
|
||||||
|
* You may use it like follow:
|
||||||
|
* ```ts
|
||||||
|
* const proxy = new TestServerReverseProxy();
|
||||||
|
* // Create here a doc and a home workers with their env variables
|
||||||
|
* proxy.requireFromOutsideHeader(); // Optional
|
||||||
|
* await proxy.start(home, docs);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class TestServerReverseProxy {
|
||||||
|
|
||||||
|
// Use a different hostname for the proxy than the doc and home workers'
|
||||||
|
// so we can ensure that either we omit the Origin header (so the internal calls to home and doc workers
|
||||||
|
// are not considered as CORS requests), or otherwise we fail because the hostnames are different
|
||||||
|
// https://github.com/gristlabs/grist-core/blob/24b39c651b9590cc360cc91b587d3e1b301a9c63/app/server/lib/requestUtils.ts#L85-L98
|
||||||
|
public static readonly HOSTNAME: string = 'grist-test-proxy.127.0.0.1.nip.io';
|
||||||
|
|
||||||
|
public static FROM_OUTSIDE_HEADER = {"X-FROM-OUTSIDE": true};
|
||||||
|
|
||||||
|
private _app = express();
|
||||||
|
private _proxyServer: http.Server;
|
||||||
|
private _proxy: httpProxy = httpProxy.createProxy();
|
||||||
|
private _address: Promise<AddressInfo>;
|
||||||
|
private _requireFromOutsideHeader = false;
|
||||||
|
|
||||||
|
public get stopped() { return !this._proxyServer.listening; }
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this._proxyServer = this._app.listen(0);
|
||||||
|
this._address = new Promise((resolve) => {
|
||||||
|
this._proxyServer.once('listening', () => {
|
||||||
|
resolve(this._proxyServer.address() as AddressInfo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require the reverse-proxy to be called from the outside world.
|
||||||
|
* This assumes that every requests to the proxy includes the header
|
||||||
|
* provided in TestServerReverseProxy.FROM_OUTSIDE_HEADER
|
||||||
|
*
|
||||||
|
* If a call is done by a worker (assuming they don't include that header),
|
||||||
|
* the proxy rejects with a FORBIDEN http status.
|
||||||
|
*/
|
||||||
|
public requireFromOutsideHeader() {
|
||||||
|
this._requireFromOutsideHeader = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(homeServer: TestServer, docServer: TestServer) {
|
||||||
|
this._app.all(['/dw/dw1', '/dw/dw1/*'], (oreq, ores) => this._getRequestHandlerFor(docServer));
|
||||||
|
this._app.all('/*', this._getRequestHandlerFor(homeServer));
|
||||||
|
|
||||||
|
// Forbid now the use of serverUrl property, so we don't allow the tests to
|
||||||
|
// call the workers directly
|
||||||
|
homeServer.disallowDirectAccess();
|
||||||
|
docServer.disallowDirectAccess();
|
||||||
|
|
||||||
|
log.info('proxy server running on ', await this.getServerUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAddress() {
|
||||||
|
return this._address;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getServerUrl() {
|
||||||
|
const address = await this.getAddress();
|
||||||
|
return `http://${TestServerReverseProxy.HOSTNAME}:${address.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop() {
|
||||||
|
if (this.stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.info("Stopping node TestServerReverseProxy");
|
||||||
|
this._proxyServer.close();
|
||||||
|
this._proxy.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getRequestHandlerFor(server: TestServer) {
|
||||||
|
const serverUrl = new URL(server.serverUrl);
|
||||||
|
|
||||||
|
return (oreq: express.Request, ores: express.Response) => {
|
||||||
|
log.debug(`[proxy] Requesting (method=${oreq.method}): ${new URL(oreq.url, serverUrl).href}`);
|
||||||
|
|
||||||
|
// See the requireFromOutsideHeader() method for the explanation
|
||||||
|
if (this._requireFromOutsideHeader && !isAffirmative(oreq.get("X-FROM-OUTSIDE"))) {
|
||||||
|
log.error('TestServerReverseProxy: called public URL from internal');
|
||||||
|
return ores.status(403).json({error: "TestServerReverseProxy: called public URL from internal "});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._proxy.web(oreq, ores, { target: serverUrl });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user