Skip to content

Commit

Permalink
(core) updates from grist-core
Browse files Browse the repository at this point in the history
  • Loading branch information
paulfitz committed Aug 1, 2023
2 parents e387c6c + 61f954f commit 8110a26
Show file tree
Hide file tree
Showing 19 changed files with 186 additions and 44 deletions.
7 changes: 7 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ jobs:
- ':nbrowser-^[M-O]:'
- ':nbrowser-^[P-S]:'
- ':nbrowser-^[^A-S]:'
include:
- tests: ':lint:python:client:common:smoke:'
node-version: 14.x
python-version: '3.10'
- tests: ':lint:python:client:common:smoke:'
node-version: 14.x
python-version: '3.11'
steps:
- uses: actions/checkout@v3

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ GRIST_DEFAULT_PRODUCT | if set, this controls enabled features and limits of ne
GRIST_DEFAULT_LOCALE | Locale to use as fallback when Grist cannot honour the browser locale.
GRIST_DOMAIN | in hosted Grist, Grist is served from subdomains of this domain. Defaults to "getgrist.com".
GRIST_EXPERIMENTAL_PLUGINS | enables experimental plugins
GRIST_ENABLE_REQUEST_FUNCTION | enables the REQUEST function. This function performs HTTP requests in a similar way to `requests.request`. This function presents a significant security risk, since it can let users call internal endpoints when Grist is available publicly. This function can also cause performance issues. Unset by default.
GRIST_HIDE_UI_ELEMENTS | comma-separated list of UI features to disable. Allowed names of parts: `helpCenter,billing,templates,multiSite,multiAccounts,sendToDrive,tutorials`. If a part also exists in GRIST_UI_FEATURES, it will still be disabled.
GRIST_HOME_INCLUDE_STATIC | if set, home server also serves static resources
GRIST_HOST | hostname to use when listening on a port.
Expand Down
2 changes: 2 additions & 0 deletions app/common/ActionBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface SandboxActionBundle {
// Represents a unique call to the Python REQUEST function
export interface SandboxRequest {
url: string;
method: string;
body?: string;
params: Record<string, string> | null;
headers: Record<string, string> | null;
deps: unknown; // pass back to the sandbox unchanged in the response
Expand Down
2 changes: 1 addition & 1 deletion app/gen-server/ApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import {getAuthorizedUserId, getUserId, getUserProfiles, RequestWithLogin} from
import {getSessionUser, linkOrgWithEmail} from 'app/server/lib/BrowserSession';
import {expressWrap} from 'app/server/lib/expressWrap';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {getTemplateOrg} from 'app/server/lib/gristSettings';
import log from 'app/server/lib/log';
import {addPermit, clearSessionCacheIfNeeded, getDocScope, getScope, integerParam,
isParameterOn, optStringParam, sendOkReply, sendReply, stringParam} from 'app/server/lib/requestUtils';
import {getTemplateOrg} from 'app/server/lib/sendAppPage';
import {IWidgetRepository} from 'app/server/lib/WidgetRepository';

import {User} from './entity/User';
Expand Down
5 changes: 5 additions & 0 deletions app/server/devServerMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export async function main() {
process.env.GRIST_EXPERIMENTAL_PLUGINS = "1";
}

// Experimental plugins are enabled by default for devs
if (!process.env.GRIST_ENABLE_REQUEST_FUNCTION) {
process.env.GRIST_ENABLE_REQUEST_FUNCTION = "1";
}

// For tests, it is useful to start with the database in a known state.
// If TEST_CLEAN_DATABASE is set, we reset the database before starting.
if (process.env.TEST_CLEAN_DATABASE) {
Expand Down
2 changes: 1 addition & 1 deletion app/server/lib/ActiveDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import {Authorizer} from 'app/server/lib/Authorizer';
import {checksumFile} from 'app/server/lib/checksumFile';
import {Client} from 'app/server/lib/Client';
import {DEFAULT_CACHE_TTL, DocManager} from 'app/server/lib/DocManager';
import {getTemplateOrg} from 'app/server/lib/gristSettings';
import {ICreateActiveDocOptions} from 'app/server/lib/ICreate';
import {makeForkIds} from 'app/server/lib/idUtils';
import {GRIST_DOC_SQL, GRIST_DOC_WITH_TABLE1_SQL} from 'app/server/lib/initialDocSql';
Expand All @@ -97,7 +98,6 @@ import log from 'app/server/lib/log';
import {LogMethods} from "app/server/lib/LogMethods";
import {NullSandbox, UnavailableSandboxMethodError} from 'app/server/lib/NullSandbox';
import {DocRequests} from 'app/server/lib/Requests';
import {getTemplateOrg} from 'app/server/lib/sendAppPage';
import {shortDesc} from 'app/server/lib/shortDesc';
import {TableMetadataLoader} from 'app/server/lib/TableMetadataLoader';
import {DocTriggers} from "app/server/lib/Triggers";
Expand Down
3 changes: 2 additions & 1 deletion app/server/lib/AppEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ import {DocStatus, IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
import {expressWrap} from 'app/server/lib/expressWrap';
import {DocTemplate, GristServer} from 'app/server/lib/GristServer';
import {getCookieDomain} from 'app/server/lib/gristSessions';
import {getTemplateOrg} from 'app/server/lib/gristSettings';
import {getAssignmentId} from 'app/server/lib/idUtils';
import log from 'app/server/lib/log';
import {adaptServerUrl, addOrgToPathIfNeeded, pruneAPIResult, trustOrigin} from 'app/server/lib/requestUtils';
import {getTemplateOrg, ISendAppPageOptions} from 'app/server/lib/sendAppPage';
import {ISendAppPageOptions} from 'app/server/lib/sendAppPage';

export interface AttachOptions {
app: express.Application; // Express app to which to add endpoints
Expand Down
11 changes: 8 additions & 3 deletions app/server/lib/Requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,21 @@ export class DocRequests {

private async _handleSingleRequestRaw(request: SandboxRequest): Promise<Response> {
try {
if (process.env.GRIST_EXPERIMENTAL_PLUGINS != '1') {
if (process.env.GRIST_ENABLE_REQUEST_FUNCTION != '1') {
throw new Error("REQUEST is not enabled");
}
const {url, params, headers} = request;
const {url, method, body, params, headers} = request;
const urlObj = new URL(url);
log.rawInfo("Handling sandbox request", {host: urlObj.host, docId: this._activeDoc.docName});
for (const [param, value] of Object.entries(params || {})) {
urlObj.searchParams.append(param, value);
}
const response = await fetch(urlObj.toString(), {headers: headers || {}, agent: proxyAgent(urlObj)});
const response = await fetch(urlObj.toString(), {
headers: headers || {},
agent: proxyAgent(urlObj),
method,
body
});
const content = await response.buffer();
const {status, statusText} = response;
const encoding = httpEncoding(response.headers.get('content-type'), content);
Expand Down
13 changes: 13 additions & 0 deletions app/server/lib/gristSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {appSettings} from 'app/server/lib/AppSettings';

export function getTemplateOrg() {
let org = appSettings.section('templates').flag('org').readString({
envVar: 'GRIST_TEMPLATE_ORG',
});
if (!org) { return null; }

if (process.env.GRIST_ID_PREFIX) {
org += `-${process.env.GRIST_ID_PREFIX}`;
}
return org;
}
14 changes: 1 addition & 13 deletions app/server/lib/sendAppPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import {isAffirmative} from 'app/common/gutil';
import {getTagManagerSnippet} from 'app/common/tagManager';
import {Document} from 'app/common/UserAPI';
import {SUPPORT_EMAIL} from 'app/gen-server/lib/HomeDBManager';
import {appSettings} from 'app/server/lib/AppSettings';
import {isAnonymousUser, isSingleUserMode, RequestWithLogin} from 'app/server/lib/Authorizer';
import {RequestWithOrg} from 'app/server/lib/extractOrg';
import {GristServer} from 'app/server/lib/GristServer';
import {getTemplateOrg} from 'app/server/lib/gristSettings';
import {getSupportedEngineChoices} from 'app/server/lib/serverUtils';
import {readLoadedLngs, readLoadedNamespaces} from 'app/server/localization';
import * as express from 'express';
Expand Down Expand Up @@ -154,18 +154,6 @@ export function makeSendAppPage(opts: {
};
}

export function getTemplateOrg() {
let org = appSettings.section('templates').flag('org').readString({
envVar: 'GRIST_TEMPLATE_ORG',
});
if (!org) { return null; }

if (process.env.GRIST_ID_PREFIX) {
org += `-${process.env.GRIST_ID_PREFIX}`;
}
return org;
}

function shouldSupportAnon() {
// Enable UI for anonymous access if a flag is explicitly set in the environment
return process.env.GRIST_SUPPORT_ANON === "true";
Expand Down
63 changes: 57 additions & 6 deletions sandbox/grist/functions/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
from __future__ import absolute_import
import datetime
import hashlib
import json
import json as json_module
import math
import numbers
import re

import chardet
import six
from six.moves import urllib_parse

import column
import docmodel
Expand Down Expand Up @@ -653,20 +654,70 @@ def is_error(value):
or (isinstance(value, float) and math.isnan(value)))


def _replicate_requests_body_args(data=None, json=None):
"""
Replicate some of the behaviour of requests.post, specifically the data and
json args.
Returns a tuple of (body, extra_headers)
"""
if data is None and json is None:
return None, {}

elif data is not None and json is None:
if isinstance(data, str):
body = data
extra_headers = {}
else:
body = urllib_parse.urlencode(data)
extra_headers = {
"Content-Type": "application/x-www-form-urlencoded",
}
return body, extra_headers

elif json is not None and data is None:
if isinstance(json, str):
body = json
else:
body = json_module.dumps(json)
extra_headers = {
"Content-Type": "application/json",
}
return body, extra_headers

elif data is not None and json is not None:
# From testing manually with requests 2.28.2, data overrides json if both
# supplied. However, this is probably a mistake on behalf of the caller, so
# we choose to throw an error instead
raise ValueError("`data` and `json` cannot be supplied to REQUEST at the same time")


@unimplemented
# ^ This excludes this function from autocomplete while in beta
# and marks it as unimplemented in the docs.
# It also makes grist-help expect to see the string 'raise NotImplemented' in the function source,
# which it does now, because of this comment. Removing this comment will currently break the docs.
def REQUEST(url, params=None, headers=None):
# Makes a GET HTTP request with an API similar to `requests.get`.
def REQUEST(url, params=None, headers=None, method="GET", data=None, json=None):
# Makes an HTTP request with an API similar to `requests.request`.
# Actually jumps through hoops internally to make the request asynchronously (usually)
# while feeling synchronous to the formula writer.

# When making a POST or PUT request, REQUEST supports `data` and `json` args, from `requests.request`:
# - `args` as str: Used as the request body
# - `args` as other types: Form encoded and used as the request body. The correct header is also set.
# - `json` as str: Used as the request body. The correct header is also set.
# - `json` as other types: JSON encoded and set as the request body. The correct header is also set.
body, _headers = _replicate_requests_body_args(data=data, json=json)

# Extra headers that make us consistent with requests.post must not override
# user-supplied headers.
_headers.update(headers or {})

# Requests are identified by a string key in various places.
# The same arguments should produce the same key so the request is only made once.
args = dict(url=url, params=params, headers=headers)
args_json = json.dumps(args, sort_keys=True)
args = dict(url=url, params=params, headers=_headers, method=method, body=body)

args_json = json_module.dumps(args, sort_keys=True)
key = hashlib.sha256(args_json.encode()).hexdigest()

# This may either return the raw response data or it may raise a special exception
Expand Down Expand Up @@ -701,7 +752,7 @@ def text(self):
return self.content.decode(self.encoding)

def json(self, **kwargs):
return json.loads(self.text, **kwargs)
return json_module.loads(self.text, **kwargs)

@property
def ok(self):
Expand Down
44 changes: 43 additions & 1 deletion sandbox/grist/test_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import test_engine
import testutil
from functions import CaseInsensitiveDict, Response, HTTPError
from functions.info import _replicate_requests_body_args


class TestCaseInsensitiveDict(unittest.TestCase):
Expand Down Expand Up @@ -73,6 +74,45 @@ def test_apparent_encoding(self):
self.assertEqual(r.text, text)


class TestRequestsPostInterface(unittest.TestCase):
def test_no_post_args(self):
body, headers = _replicate_requests_body_args()

assert body is None
assert headers == {}

def test_data_as_dict(self):
body, headers = _replicate_requests_body_args(data={"foo": "bar"})

assert body == "foo=bar"
assert headers == {"Content-Type": "application/x-www-form-urlencoded"}

def test_data_as_string(self):
body, headers = _replicate_requests_body_args(data="some_content")

assert body == "some_content"
assert headers == {}

def test_json_as_dict(self):
body, headers = _replicate_requests_body_args(json={"foo": "bar"})

assert body == '{"foo": "bar"}'
assert headers == {"Content-Type": "application/json"}

def test_json_as_string(self):
body, headers = _replicate_requests_body_args(json="invalid_but_ignored")

assert body == "invalid_but_ignored"
assert headers == {"Content-Type": "application/json"}

def test_data_and_json_together(self):
with self.assertRaises(ValueError):
body, headers = _replicate_requests_body_args(
json={"foo": "bar"},
data={"quux": "jazz"}
)


class TestRequestFunction(test_engine.EngineTestCase):
sample = testutil.parse_test_sample({
"SCHEMA": [
Expand All @@ -98,12 +138,14 @@ def test_request_function(self):
r.__dict__
"""
out_actions = self.modify_column("Table1", "Request", formula=formula)
key = '9d305be9664924aaaf7ebb0bab2e4155d1fa1b9dcde53e417f1a9f9a2c7e09b9'
key = 'd7f8cedf177ab538bf7dadf66e77a525486a29a41ce4520b2c89a33e39095fed'
deps = {'Table1': {'Request': [1, 2]}}
args = {
'url': 'my_url',
'headers': {'foo': 'bar'},
'params': {'a': 2, 'b': 1},
'method': 'GET',
'body': None,
'deps': deps,
}
self.assertEqual(out_actions.requests, {key: args})
Expand Down
8 changes: 6 additions & 2 deletions static/locales/en.client.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@
"Activation": "Activation",
"Billing Account": "Billing Account",
"Support Grist": "Support Grist",
"Upgrade Plan": "Upgrade Plan"
"Upgrade Plan": "Upgrade Plan",
"Sign In": "Sign In",
"Sign Up": "Sign Up",
"Use This Template": "Use This Template"
},
"ViewAsDropdown": {
"View As": "View As",
Expand Down Expand Up @@ -112,7 +115,8 @@
"Home Page": "Home Page",
"Legacy": "Legacy",
"Personal Site": "Personal Site",
"Team Site": "Team Site"
"Team Site": "Team Site",
"Grist Templates": "Grist Templates"
},
"AppModel": {
"This team site is suspended. Documents can be read, but not modified.": "This team site is suspended. Documents can be read, but not modified."
Expand Down
1 change: 1 addition & 0 deletions test/nbrowser/CellColor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ describe('CellColor', function() {
// Empty cell to clear error from converting toggle to date
await cell.click();
await driver.sendKeys(Key.DELETE);
await gu.waitAppFocus(true);

const clip = cell.find('.field_clip');

Expand Down
2 changes: 1 addition & 1 deletion test/nbrowser/SelectByRefList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ async function checkSelectingRecords(selectBy: string, sourceData: string[][], n
for (let rowNum = 1; rowNum <= 3; rowNum++) {
// Click an anchor link
const anchorCell = gu.getCell({section: "Anchors", rowNum, col: 1});
await anchorCell.find('.test-tb-link').click();
await driver.withActions(a => a.click(anchorCell.find('.test-tb-link')));

// Check that navigation to the link target worked
assert.equal(await gu.getActiveSectionTitle(), "LINKTARGET");
Expand Down
Loading

0 comments on commit 8110a26

Please sign in to comment.