(core) updates from grist-core

This commit is contained in:
Paul Fitzpatrick 2023-09-05 11:12:15 -04:00
commit 6dab12f301
25 changed files with 673 additions and 31 deletions

View File

@ -170,7 +170,7 @@ export function pagePanels(page: PageContents) {
}, },
}), }),
// opening left panel on over // opening left panel on hover
dom.on('mouseenter', (evt1, elem) => { dom.on('mouseenter', (evt1, elem) => {

View File

@ -1,6 +1,5 @@
import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions'; import {parsePermissions, permissionSetToText, splitSchemaEditPermissionSet} from 'app/common/ACLPermissions';
import {AclRuleProblem} from 'app/common/ActiveDocAPI'; import {AclRuleProblem} from 'app/common/ActiveDocAPI';
import {ILogger} from 'app/common/BaseAPI';
import {DocData} from 'app/common/DocData'; import {DocData} from 'app/common/DocData';
import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause'; import {AclMatchFunc, ParsedAclFormula, RulePart, RuleSet, UserAttributeRule} from 'app/common/GranularAccessClause';
import {getSetMapValue, isNonNullish} from 'app/common/gutil'; import {getSetMapValue, isNonNullish} from 'app/common/gutil';
@ -8,6 +7,8 @@ import {MetaRowRecord} from 'app/common/TableData';
import {decodeObject} from 'app/plugin/objtypes'; import {decodeObject} from 'app/plugin/objtypes';
import sortBy = require('lodash/sortBy'); import sortBy = require('lodash/sortBy');
export type ILogger = Pick<Console, 'log'|'debug'|'info'|'warn'|'error'>;
const defaultMatchFunc: AclMatchFunc = () => true; const defaultMatchFunc: AclMatchFunc = () => true;
export const SPECIAL_RULES_TABLE_ID = '*SPECIAL'; export const SPECIAL_RULES_TABLE_ID = '*SPECIAL';

View File

@ -2,13 +2,10 @@ import {ApiError, ApiErrorDetails} from 'app/common/ApiError';
import axios, {AxiosRequestConfig, AxiosResponse} from 'axios'; import axios, {AxiosRequestConfig, AxiosResponse} from 'axios';
import {tbind} from './tbind'; import {tbind} from './tbind';
export type ILogger = Pick<Console, 'log'|'debug'|'info'|'warn'|'error'>;
export interface IOptions { export interface IOptions {
headers?: Record<string, string>; headers?: Record<string, string>;
fetch?: typeof fetch; fetch?: typeof fetch;
newFormData?: () => FormData; // constructor for FormData depends on platform. newFormData?: () => FormData; // constructor for FormData depends on platform.
logger?: ILogger;
extraParameters?: Map<string, string>; // if set, add query parameters to requests. extraParameters?: Map<string, string>; // if set, add query parameters to requests.
} }
@ -54,13 +51,11 @@ export class BaseAPI {
protected fetch: typeof fetch; protected fetch: typeof fetch;
protected newFormData: () => FormData; protected newFormData: () => FormData;
private _headers: Record<string, string>; private _headers: Record<string, string>;
private _logger: ILogger;
private _extraParameters?: Map<string, string>; private _extraParameters?: Map<string, string>;
constructor(options: IOptions = {}) { constructor(options: IOptions = {}) {
this.fetch = options.fetch || tbind(window.fetch, window); this.fetch = options.fetch || tbind(window.fetch, window);
this.newFormData = options.newFormData || (() => new FormData()); this.newFormData = options.newFormData || (() => new FormData());
this._logger = options.logger || console;
this._headers = { this._headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
@ -116,7 +111,6 @@ export class BaseAPI {
} }
} }
const resp = await this.fetch(input, init); const resp = await this.fetch(input, init);
this._logger.log("Fetched", input);
if (resp.status !== 200) { if (resp.status !== 200) {
const body = await resp.json().catch(() => ({})); const body = await resp.json().catch(() => ({}));
throwApiError(input, resp, body); throwApiError(input, resp, body);

View File

@ -61,6 +61,7 @@ export class DocApiForwarder {
app.use('/api/docs/:docId/webhooks/queue', withDoc); app.use('/api/docs/:docId/webhooks/queue', withDoc);
app.use('/api/docs/:docId/webhooks', withDoc); app.use('/api/docs/:docId/webhooks', withDoc);
app.use('/api/docs/:docId/assistant', withDoc); app.use('/api/docs/:docId/assistant', withDoc);
app.use('/api/docs/:docId/sql', withDoc);
app.use('^/api/docs$', withoutDoc); app.use('^/api/docs$', withoutDoc);
} }

View File

@ -84,6 +84,12 @@ export const TablesPatch = t.iface([], {
"tables": t.tuple("RecordWithStringId", t.rest(t.array("RecordWithStringId"))), "tables": t.tuple("RecordWithStringId", t.rest(t.array("RecordWithStringId"))),
}); });
export const SqlPost = t.iface([], {
"sql": "string",
"args": t.opt(t.array("any")),
"timeout": t.opt("number"),
});
const exportedTypeSuite: t.ITypeSuite = { const exportedTypeSuite: t.ITypeSuite = {
NewRecord, NewRecord,
NewRecordWithStringId, NewRecordWithStringId,
@ -101,5 +107,6 @@ const exportedTypeSuite: t.ITypeSuite = {
TablePost, TablePost,
TablesPost, TablesPost,
TablesPatch, TablesPatch,
SqlPost,
}; };
export default exportedTypeSuite; export default exportedTypeSuite;

View File

@ -107,3 +107,16 @@ export interface TablesPost {
export interface TablesPatch { export interface TablesPatch {
tables: [RecordWithStringId, ...RecordWithStringId[]]; // at least one table is required tables: [RecordWithStringId, ...RecordWithStringId[]]; // at least one table is required
} }
/**
* JSON schema for the body of api /sql POST endpoint
*/
export interface SqlPost {
sql: string;
args?: any[]; // (It would be nice to support named parameters, but
// that feels tricky currently with node-sqlite3)
timeout?: number; // In msecs. Can only be reduced from server default,
// not increased. Note timeout of a query could affect
// other queued queries on same document, because of
// limitations of API node-sqlite3 exposes.
}

View File

@ -914,6 +914,11 @@ export class ActiveDoc extends EventEmitter {
return this._granularAccess.canCopyEverything(docSession); return this._granularAccess.canCopyEverything(docSession);
} }
// Check if user has rights to read everything in this doc.
public async canCopyEverything(docSession: OptDocSession) {
return this._granularAccess.canCopyEverything(docSession);
}
// Check if it is appropriate for the user to be treated as an owner of // Check if it is appropriate for the user to be treated as an owner of
// the document for granular access purposes when in "prefork" mode // the document for granular access purposes when in "prefork" mode
// (meaning a document has been opened with the intent to fork it, but // (meaning a document has been opened with the intent to fork it, but

View File

@ -96,6 +96,17 @@ export class AppSettings {
return result; return result;
} }
/**
* As for readInt() but fail if nothing was found.
*/
public requireInt(query: AppSettingQuery): number {
const result = this.readInt(query);
if (result === undefined) {
throw new Error(`missing environment variable: ${query.envVar}`);
}
return result;
}
/** /**
* As for read() but type (and store, and report) the result as * As for read() but type (and store, and report) the result as
* a boolean. * a boolean.
@ -107,6 +118,17 @@ export class AppSettings {
return result; return result;
} }
/**
* As for read() but type (and store, and report) the result as
* an integer (well, a number).
*/
public readInt(query: AppSettingQuery): number|undefined {
this.readString(query);
const result = this.getAsInt();
this._value = result;
return result;
}
/* set this setting 'manually' */ /* set this setting 'manually' */
public set(value: JSONValue): void { public set(value: JSONValue): void {
this._value = value; this._value = value;

View File

@ -32,6 +32,7 @@ import {
TableOperationsPlatform TableOperationsPlatform
} from 'app/plugin/TableOperationsImpl'; } from 'app/plugin/TableOperationsImpl';
import {ActiveDoc, colIdToRef as colIdToReference, tableIdToRef} from "app/server/lib/ActiveDoc"; import {ActiveDoc, colIdToRef as colIdToReference, tableIdToRef} from "app/server/lib/ActiveDoc";
import {appSettings} from "app/server/lib/AppSettings";
import {sendForCompletion} from 'app/server/lib/Assistance'; import {sendForCompletion} from 'app/server/lib/Assistance';
import { import {
assertAccess, assertAccess,
@ -96,16 +97,25 @@ const MAX_PARALLEL_REQUESTS_PER_DOC = 10;
// then the _dailyUsage cache may become unreliable and users may be able to exceed their allocated requests. // then the _dailyUsage cache may become unreliable and users may be able to exceed their allocated requests.
const MAX_ACTIVE_DOCS_USAGE_CACHE = 1000; const MAX_ACTIVE_DOCS_USAGE_CACHE = 1000;
// Maximum duration of a call to /sql. Does not apply to internal calls to SQLite.
const MAX_CUSTOM_SQL_MSEC = appSettings.section('integrations')
.section('sql').flag('timeout').requireInt({
envVar: 'GRIST_SQL_TIMEOUT_MSEC',
defaultValue: 1000,
});
type WithDocHandler = (activeDoc: ActiveDoc, req: RequestWithLogin, resp: Response) => Promise<void>; type WithDocHandler = (activeDoc: ActiveDoc, req: RequestWithLogin, resp: Response) => Promise<void>;
// Schema validators for api endpoints that creates or updates records. // Schema validators for api endpoints that creates or updates records.
const { const {
RecordsPatch, RecordsPost, RecordsPut, RecordsPatch, RecordsPost, RecordsPut,
ColumnsPost, ColumnsPatch, ColumnsPut, ColumnsPost, ColumnsPatch, ColumnsPut,
SqlPost,
TablesPost, TablesPatch, TablesPost, TablesPatch,
} = t.createCheckers(DocApiTypesTI, GristDataTI); } = t.createCheckers(DocApiTypesTI, GristDataTI);
for (const checker of [RecordsPatch, RecordsPost, RecordsPut, ColumnsPost, ColumnsPatch, TablesPost, TablesPatch]) { for (const checker of [RecordsPatch, RecordsPost, RecordsPut, ColumnsPost, ColumnsPatch,
SqlPost, TablesPost, TablesPatch]) {
checker.setReportedPath("body"); checker.setReportedPath("body");
} }
@ -518,6 +528,27 @@ export class DocWorkerApi {
}) })
); );
// A GET /sql endpoint that takes a query like ?q=select+*+from+Table1
// Not very useful, apart from testing - see the POST endpoint for
// serious use.
// If SQL statements that modify the DB are ever supported, they should
// not be permitted by this endpoint.
this._app.get(
'/api/docs/:docId/sql', canView,
withDoc(async (activeDoc, req, res) => {
const sql = stringParam(req.query.q, 'q');
await this._runSql(activeDoc, req, res, { sql });
}));
// A POST /sql endpoint, accepting a body like:
// { "sql": "select * from Table1 where name = ?", "args": ["Paul"] }
// Only SELECT statements are currently supported.
this._app.post(
'/api/docs/:docId/sql', canView, validate(SqlPost),
withDoc(async (activeDoc, req, res) => {
await this._runSql(activeDoc, req, res, req.body);
}));
// Create columns in a table, given as records of the _grist_Tables_column metatable. // Create columns in a table, given as records of the _grist_Tables_column metatable.
this._app.post('/api/docs/:docId/tables/:tableId/columns', canEdit, validate(ColumnsPost), this._app.post('/api/docs/:docId/tables/:tableId/columns', canEdit, validate(ColumnsPost),
withDoc(async (activeDoc, req, res) => { withDoc(async (activeDoc, req, res) => {
@ -1508,6 +1539,73 @@ export class DocWorkerApi {
await this._dbManager.flushSingleDocAuthCache(scope, docId); await this._dbManager.flushSingleDocAuthCache(scope, docId);
await this._docManager.interruptDocClients(docId); await this._docManager.interruptDocClients(docId);
} }
private async _runSql(activeDoc: ActiveDoc, req: RequestWithLogin, res: Response,
options: Types.SqlPost) {
if (!await activeDoc.canCopyEverything(docSessionFromRequest(req))) {
throw new ApiError('insufficient document access', 403);
}
const statement = options.sql;
// A very loose test, just for early error message
if (!(statement.toLowerCase().includes('select'))) {
throw new ApiError('only select statements are supported', 400);
}
const sqlOptions = activeDoc.docStorage.getOptions();
if (!sqlOptions?.canInterrupt || !sqlOptions?.bindableMethodsProcessOneStatement) {
throw new ApiError('The available SQLite wrapper is not adequate', 500);
}
const timeout =
Math.max(0, Math.min(MAX_CUSTOM_SQL_MSEC,
optIntegerParam(options.timeout) || MAX_CUSTOM_SQL_MSEC));
// Wrap in a select to commit to the SELECT branch of SQLite
// grammar. Note ; isn't a problem.
//
// The underlying SQLite functions used will only process the
// first statement in the supplied text. For node-sqlite3, the
// remainder is placed in a "tail string" ignored by that library.
// So a Robert'); DROP TABLE Students;-- style attack isn't applicable.
//
// Since Grist is used with multiple SQLite wrappers, not just
// node-sqlite3, we have added a bindableMethodsProcessOneStatement
// flag that will need adding for each wrapper, and this endpoint
// will not operate unless that flag is set to true.
//
// The text is wrapped in select * from (USER SUPPLIED TEXT) which
// puts SQLite unconditionally onto the SELECT branch of its
// grammar. It is straightforward to break out of such a wrapper
// with multiple statements, but again, only the first statement
// is processed.
const wrappedStatement = `select * from (${statement})`;
const interrupt = setTimeout(async () => {
await activeDoc.docStorage.interrupt();
}, timeout);
try {
const records = await activeDoc.docStorage.all(wrappedStatement,
...(options.args || []));
res.status(200).json({
statement,
records: records.map(
rec => ({
fields: rec,
})
),
});
} catch (e) {
if (e?.code === 'SQLITE_INTERRUPT') {
res.status(400).json({
error: "a slow statement resulted in a database interrupt",
});
} else if (e?.code === 'SQLITE_ERROR') {
res.status(400).json({
error: e?.message,
});
} else {
throw e;
}
} finally {
clearTimeout(interrupt);
}
}
} }
export function addDocApiRoutes( export function addDocApiRoutes(

View File

@ -31,6 +31,7 @@ import {ISQLiteDB, MigrationHooks, OpenMode, PreparedStatement, quoteIdent,
import chunk = require('lodash/chunk'); import chunk = require('lodash/chunk');
import cloneDeep = require('lodash/cloneDeep'); import cloneDeep = require('lodash/cloneDeep');
import groupBy = require('lodash/groupBy'); import groupBy = require('lodash/groupBy');
import { MinDBOptions } from './SqliteCommon';
// Run with environment variable NODE_DEBUG=db (may include additional comma-separated sections) // Run with environment variable NODE_DEBUG=db (may include additional comma-separated sections)
@ -1419,6 +1420,14 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
} }
} }
public interrupt(): Promise<void> {
return this._getDB().interrupt();
}
public getOptions(): MinDBOptions|undefined {
return this._getDB().getOptions();
}
public all(sql: string, ...args: any[]): Promise<ResultRow[]> { public all(sql: string, ...args: any[]): Promise<ResultRow[]> {
return this._getDB().all(sql, ...args); return this._getDB().all(sql, ...args);
} }

View File

@ -72,7 +72,7 @@ import {timeFormat} from 'app/common/timeFormat';
import {create} from 'app/server/lib/create'; import {create} from 'app/server/lib/create';
import * as docUtils from 'app/server/lib/docUtils'; import * as docUtils from 'app/server/lib/docUtils';
import log from 'app/server/lib/log'; import log from 'app/server/lib/log';
import {MinDB, MinRunResult, PreparedStatement, ResultRow, import {MinDB, MinDBOptions, MinRunResult, PreparedStatement, ResultRow,
SqliteVariant, Statement} from 'app/server/lib/SqliteCommon'; SqliteVariant, Statement} from 'app/server/lib/SqliteCommon';
import {NodeSqliteVariant} from 'app/server/lib/SqliteNode'; import {NodeSqliteVariant} from 'app/server/lib/SqliteNode';
import assert from 'assert'; import assert from 'assert';
@ -258,6 +258,14 @@ export class SQLiteDB implements ISQLiteDB {
private constructor(protected _db: MinDB, private _dbPath: string) { private constructor(protected _db: MinDB, private _dbPath: string) {
} }
public async interrupt(): Promise<void> {
return this._db.interrupt?.();
}
public getOptions(): MinDBOptions|undefined {
return this._db.getOptions?.();
}
public async all(sql: string, ...args: any[]): Promise<ResultRow[]> { public async all(sql: string, ...args: any[]): Promise<ResultRow[]> {
const result = await this._db.all(sql, ...args); const result = await this._db.all(sql, ...args);
return result; return result;

View File

@ -12,20 +12,50 @@ import { OpenMode, quoteIdent } from 'app/server/lib/SQLiteDB';
// eslint-disable-next-line @typescript-eslint/no-empty-interface // eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Statement {} export interface Statement {}
// Some facts about the wrapper implementation.
export interface MinDBOptions {
// is interruption implemented?
canInterrupt: boolean;
// Do all methods apart from exec() process at most one
// statement?
bindableMethodsProcessOneStatement: boolean;
}
export interface MinDB { export interface MinDB {
// This method is expected to be able to handle multiple
// semicolon-separated statements, as for sqlite3_exec:
// https://www.sqlite.org/c3ref/exec.html
exec(sql: string): Promise<void>; exec(sql: string): Promise<void>;
// For all these methods, sql should ultimately be passed
// to sqlite3_prepare_v2 or later, and any tail text ignored after
// the first complete statement, so only the first statement is
// used if there are multiple.
// https://www.sqlite.org/c3ref/prepare.html
run(sql: string, ...params: any[]): Promise<MinRunResult>; run(sql: string, ...params: any[]): Promise<MinRunResult>;
get(sql: string, ...params: any[]): Promise<ResultRow|undefined>; get(sql: string, ...params: any[]): Promise<ResultRow|undefined>;
all(sql: string, ...params: any[]): Promise<ResultRow[]>; all(sql: string, ...params: any[]): Promise<ResultRow[]>;
prepare(sql: string, ...params: any[]): Promise<PreparedStatement>; prepare(sql: string, ...params: any[]): Promise<PreparedStatement>;
runAndGetId(sql: string, ...params: any[]): Promise<number>; runAndGetId(sql: string, ...params: any[]): Promise<number>;
close(): Promise<void>;
allMarshal(sql: string, ...params: any[]): Promise<Buffer>; allMarshal(sql: string, ...params: any[]): Promise<Buffer>;
close(): Promise<void>;
/** /**
* Limit the number of ATTACHed databases permitted. * Limit the number of ATTACHed databases permitted.
*/ */
limitAttach(maxAttach: number): Promise<void>; limitAttach(maxAttach: number): Promise<void>;
/**
* Stop all current queries.
*/
interrupt?(): Promise<void>;
/**
* Get some facts about the wrapper.
*/
getOptions?(): MinDBOptions;
} }
export interface MinRunResult { export interface MinRunResult {

View File

@ -1,6 +1,6 @@
import * as sqlite3 from '@gristlabs/sqlite3'; import * as sqlite3 from '@gristlabs/sqlite3';
import { fromCallback } from 'app/server/lib/serverUtils'; import { fromCallback } from 'app/server/lib/serverUtils';
import { MinDB, PreparedStatement, ResultRow, SqliteVariant } from 'app/server/lib/SqliteCommon'; import { MinDB, MinDBOptions, PreparedStatement, ResultRow, SqliteVariant } from 'app/server/lib/SqliteCommon';
import { OpenMode, RunResult } from 'app/server/lib/SQLiteDB'; import { OpenMode, RunResult } from 'app/server/lib/SQLiteDB';
export class NodeSqliteVariant implements SqliteVariant { export class NodeSqliteVariant implements SqliteVariant {
@ -84,6 +84,17 @@ export class NodeSqlite3DatabaseAdapter implements MinDB {
this._db.close(); this._db.close();
} }
public async interrupt(): Promise<void> {
this._db.interrupt();
}
public getOptions(): MinDBOptions {
return {
canInterrupt: true,
bindableMethodsProcessOneStatement: true,
};
}
public async allMarshal(sql: string, ...params: any[]): Promise<Buffer> { public async allMarshal(sql: string, ...params: any[]): Promise<Buffer> {
// allMarshal isn't in the typings, because it is our addition to our fork of sqlite3 JS lib. // allMarshal isn't in the typings, because it is our addition to our fork of sqlite3 JS lib.
return fromCallback(cb => (this._db as any).allMarshal(sql, ...params, cb)); return fromCallback(cb => (this._db as any).allMarshal(sql, ...params, cb));

View File

@ -21,6 +21,7 @@ save_packages:
clean_packages: clean_packages:
rm -rf _build/packages rm -rf _build/packages
rm -rf _build/pyodide/grist-packages
setup: setup:
./setup.sh ./setup.sh

View File

@ -1,7 +1,7 @@
[ [
"astroid-2.14.2-cp311-none-any.whl", "astroid-2.14.2-cp311-none-any.whl",
"asttokens-2.2.1-cp311-none-any.whl", "asttokens-2.2.1-cp311-none-any.whl",
"chardet-4.0.0-cp311-none-any.whl", "chardet-5.1.0-cp311-none-any.whl",
"et_xmlfile-1.0.1-cp311-none-any.whl", "et_xmlfile-1.0.1-cp311-none-any.whl",
"executing-1.1.1-cp311-none-any.whl", "executing-1.1.1-cp311-none-any.whl",
"friendly_traceback-0.7.48-cp311-none-any.whl", "friendly_traceback-0.7.48-cp311-none-any.whl",

View File

@ -522,7 +522,10 @@
"Update Original": "Original aktualisieren", "Update Original": "Original aktualisieren",
"Workspace": "Arbeitsbereich", "Workspace": "Arbeitsbereich",
"You do not have write access to the selected workspace": "Sie haben keinen Schreibzugriff auf den ausgewählten Arbeitsbereich", "You do not have write access to the selected workspace": "Sie haben keinen Schreibzugriff auf den ausgewählten Arbeitsbereich",
"You do not have write access to this site": "Sie haben keinen Schreibzugriff auf diese Seite" "You do not have write access to this site": "Sie haben keinen Schreibzugriff auf diese Seite",
"Download full document and history": "Vollständiges Dokument und Geschichte herunterladen",
"Remove all data but keep the structure to use as a template": "Entfernen Sie alle Daten, behalten Sie aber die Struktur als Vorlage bei",
"Remove document history (can significantly reduce file size)": "Dokumentverlauf entfernen (kann die Dateigröße deutlich reduzieren)"
}, },
"NTextBox": { "NTextBox": {
"false": "falsch", "false": "falsch",
@ -659,7 +662,8 @@
"Show in folder": "Im Ordner anzeigen", "Show in folder": "Im Ordner anzeigen",
"Unsaved": "Ungespeichert", "Unsaved": "Ungespeichert",
"Work on a Copy": "Arbeiten an einer Kopie", "Work on a Copy": "Arbeiten an einer Kopie",
"Share": "Teilen" "Share": "Teilen",
"Download...": "Herunterladen..."
}, },
"SiteSwitcher": { "SiteSwitcher": {
"Create new team site": "Neue Teamseite erstellen", "Create new team site": "Neue Teamseite erstellen",

View File

@ -425,7 +425,10 @@
"Update Original": "Actualizar original", "Update Original": "Actualizar original",
"Workspace": "Espacio de trabajo", "Workspace": "Espacio de trabajo",
"You do not have write access to the selected workspace": "No tienes acceso de escritura al espacio de trabajo seleccionado", "You do not have write access to the selected workspace": "No tienes acceso de escritura al espacio de trabajo seleccionado",
"You do not have write access to this site": "No tiene acceso de escritura a este sitio" "You do not have write access to this site": "No tiene acceso de escritura a este sitio",
"Download full document and history": "Descargar documento completo e historial",
"Remove all data but keep the structure to use as a template": "Elimine todos los datos pero mantenga la estructura para usarla como plantilla",
"Remove document history (can significantly reduce file size)": "Eliminar el historial del documento (puede reducir significativamente el tamaño del archivo)"
}, },
"NTextBox": { "NTextBox": {
"false": "falso", "false": "falso",
@ -537,7 +540,8 @@
"Show in folder": "Mostrar en la carpeta", "Show in folder": "Mostrar en la carpeta",
"Unsaved": "No guardado", "Unsaved": "No guardado",
"Work on a Copy": "Trabajar en una copia", "Work on a Copy": "Trabajar en una copia",
"Share": "Compartir" "Share": "Compartir",
"Download...": "Descargar..."
}, },
"SiteSwitcher": { "SiteSwitcher": {
"Create new team site": "Crear nuevo sitio de equipo", "Create new team site": "Crear nuevo sitio de equipo",

View File

@ -785,7 +785,8 @@
"Home Page": "Página inicial", "Home Page": "Página inicial",
"Legacy": "Legado", "Legacy": "Legado",
"Personal Site": "Site pessoal", "Personal Site": "Site pessoal",
"Team Site": "Site da Equipa" "Team Site": "Site da Equipa",
"Grist Templates": "Modelos Grist"
}, },
"AppModel": { "AppModel": {
"This team site is suspended. Documents can be read, but not modified.": "Este site da equipa está suspenso. Os documentos podem ser lidos, mas não modificados." "This team site is suspended. Documents can be read, but not modified.": "Este site da equipa está suspenso. Os documentos podem ser lidos, mas não modificados."

View File

@ -522,7 +522,10 @@
"Update Original": "Atualizar o Original", "Update Original": "Atualizar o Original",
"Workspace": "Área de Trabalho", "Workspace": "Área de Trabalho",
"You do not have write access to the selected workspace": "Você não tem acesso de gravação na área de trabalho selecionada", "You do not have write access to the selected workspace": "Você não tem acesso de gravação na área de trabalho selecionada",
"You do not have write access to this site": "Você não tem acesso de gravação a este site" "You do not have write access to this site": "Você não tem acesso de gravação a este site",
"Download full document and history": "Baixe documento completo e histórico",
"Remove all data but keep the structure to use as a template": "Remova todos os dados, mas mantenha a estrutura para usar como um modelo",
"Remove document history (can significantly reduce file size)": "Remova o histórico do documento (pode reduzir significativamente o tamanho do arquivo)"
}, },
"NTextBox": { "NTextBox": {
"false": "falso", "false": "falso",
@ -659,7 +662,8 @@
"Show in folder": "Mostrar na pasta", "Show in folder": "Mostrar na pasta",
"Unsaved": "Não Salvo", "Unsaved": "Não Salvo",
"Work on a Copy": "Trabalho em uma cópia", "Work on a Copy": "Trabalho em uma cópia",
"Share": "Compartilhar" "Share": "Compartilhar",
"Download...": "Baixar..."
}, },
"SiteSwitcher": { "SiteSwitcher": {
"Create new team site": "Criar novo site de equipe", "Create new team site": "Criar novo site de equipe",

View File

@ -260,7 +260,10 @@
"Replacing the original requires editing rights on the original document.": "Для замены оригинала требуются права на редактирование исходного документа.", "Replacing the original requires editing rights on the original document.": "Для замены оригинала требуются права на редактирование исходного документа.",
"To save your changes, please sign up, then reload this page.": "Чтобы сохранить изменения, пожалуйста зарегистрируйтесь, а затем перезагрузите эту страницу.", "To save your changes, please sign up, then reload this page.": "Чтобы сохранить изменения, пожалуйста зарегистрируйтесь, а затем перезагрузите эту страницу.",
"You do not have write access to the selected workspace": "У вас нет прав на запись в выбранное рабочее пространство", "You do not have write access to the selected workspace": "У вас нет прав на запись в выбранное рабочее пространство",
"You do not have write access to this site": "У вас нет права записи для этого сайта" "You do not have write access to this site": "У вас нет права записи для этого сайта",
"Remove all data but keep the structure to use as a template": "Удалить все данные, но сохранить структуру для использования в качестве шаблона.",
"Download full document and history": "Скачать полный документ и историю",
"Remove document history (can significantly reduce file size)": "Удалить историю документа (может значительно уменьшить размер файла)"
}, },
"ShareMenu": { "ShareMenu": {
"Back to Current": "Вернуться к текущему", "Back to Current": "Вернуться к текущему",
@ -282,7 +285,8 @@
"Send to Google Drive": "Отправить в Google Диск", "Send to Google Drive": "Отправить в Google Диск",
"Save Document": "Сохранить документ", "Save Document": "Сохранить документ",
"Work on a Copy": "Работа над копией", "Work on a Copy": "Работа над копией",
"Share": "Поделиться" "Share": "Поделиться",
"Download...": "Скачать..."
}, },
"SortConfig": { "SortConfig": {
"Search Columns": "Поиск по столбцам", "Search Columns": "Поиск по столбцам",

View File

@ -0,0 +1,259 @@
{
"AccessRules": {
"Delete Table Rules": "Brisanje pravil tabele",
"Allow editors to edit structure (e.g. modify and delete tables, columns, layouts), and to write formulas, which give access to all data regardless of read restrictions.": "Urednikom omogočite urejanje strukture (npr. spreminjanje in brisanje tabel, stolpcev, postavitev) in pisanje formul, ki omogočajo dostop do vseh podatkov ne glede na omejitve branja.",
"Default Rules": "Privzeta pravila",
"Invalid": "Neveljavno",
"Lookup Column": "Stolpec za iskanje",
"Permission to access the document in full when needed": "Dovoljenje za dostop do celotnega dokumenta, kadar je to potrebno.",
"Permission to view Access Rules": "Dovoljenje za ogled pravil za dostop",
"Permissions": "Dovoljenja",
"Remove column {{- colId }} from {{- tableId }} rules": "Odstranitev stolpca {{- colId }} iz pravil {{- tableId }}",
"Remove {{- tableId }} rules": "Odstranitev pravil {{- tableId }}",
"Remove {{- name }} user attribute": "Odstranitev uporabniškega atributa {{- name }}",
"Reset": "Ponastavitev",
"Rules for table ": "Pravila za mizo ",
"Save": "Shrani",
"Saved": "Shranjeno",
"Special Rules": "Posebna pravila",
"Type a message...": "Vnesite sporočilo…",
"User Attributes": "Atributi uporabnika",
"View As": "Poglej kot",
"When adding table rules, automatically add a rule to grant OWNER full access.": "Pri dodajanju pravil za tabele samodejno dodajte pravilo, ki lastniku omogoča popoln dostop.",
"Permission to edit document structure": "Dovoljenje za urejanje strukture dokumenta",
"Everyone": "Vsi",
"Everyone Else": "Vsi ostali",
"Checking...": "Preverjanje…",
"Condition": "Stanje",
"Enter Condition": "Vnesite pogoj",
"Add Column Rule": "Dodajanje pravila za stolpce",
"Add Default Rule": "Dodaj privzeto pravilo",
"Add Table Rules": "Dodajanje pravil tabele",
"Add User Attributes": "Dodajanje atributov uporabnika",
"Allow everyone to copy the entire document, or view it in full in fiddle mode.\nUseful for examples and templates, but not for sensitive data.": "Vsakomur omogočite kopiranje celotnega dokumenta ali pa si ga oglejte v celoti v načinu fiddle.\nUporabno za primere in predloge, ne pa za občutljive podatke.",
"Allow everyone to view Access Rules.": "Vsakomur omogočite ogled pravil za dostop.",
"Attribute name": "Ime atributa",
"Attribute to Look Up": "Atribut za iskanje"
},
"ACUserManager": {
"We'll email an invite to {{email}}": "Vabilo bomo poslali po e-pošti {{email}}",
"Enter email address": "Vnesite e-poštni naslov",
"Invite new member": "Povabite novega člana"
},
"AccountPage": {
"API": "API",
"Account settings": "Nastavitve računa",
"Allow signing in to this account with Google": "Omogočanje prijave v ta račun z Googlom",
"Change Password": "Sprememba gesla",
"Email": "E-naslov",
"Name": "Ime",
"Names only allow letters, numbers and certain special characters": "Imena dovoljujejo samo črke, številke in nekatere posebne znake.",
"Password & Security": "Geslo in varnost",
"Save": "Shrani",
"Theme": "Tema",
"Two-factor authentication": "Preverjanje pristnosti z dvema dejavnikoma",
"Language": "Jezik",
"Edit": "Uredi",
"Two-factor authentication is an extra layer of security for your Grist account designed to ensure that you're the only person who can access your account, even if someone knows your password.": "Dvostopenjsko preverjanje pristnosti je dodatna stopnja varnosti za vaš račun Grist, ki zagotavlja, da ste edina oseba, ki lahko dostopa do vašega računa, tudi če nekdo pozna vaše geslo.",
"Login Method": "Metoda prijave",
"API Key": "API ključ"
},
"AccountWidget": {
"Access Details": "Podrobnosti o dostopu",
"Accounts": "Računi",
"Add Account": "Dodajanje računa",
"Document Settings": "Nastavitve dokumentov",
"Manage Team": "Upravljanje ekipe",
"Pricing": "Oblikovanje cen",
"Profile Settings": "Nastavitve profila",
"Sign Out": "Odjavite se",
"Sign in": "Prijavite se",
"Switch Accounts": "Preklop računov",
"Toggle Mobile Mode": "Preklapljanje mobilnega načina",
"Activation": "Aktivacija",
"Billing Account": "Račun za zaračunavanje",
"Support Grist": "Podpora Grist",
"Upgrade Plan": "Načrt nadgradnje",
"Sign In": "Prijavite se",
"Use This Template": "Uporabite to predlogo",
"Sign Up": "Prijava"
},
"ViewAsDropdown": {
"View As": "Poglej kot",
"Users from table": "Uporabniki iz tabele"
},
"ActionLog": {
"Column {{colId}} was subsequently removed in action #{{action.actionNum}}": "Stolpec {{colId}} je bil pozneje odstranjen v akciji #{{action.actionNum}}",
"Action Log failed to load": "Dnevnik ukrepov se ni uspel naložiti",
"This row was subsequently removed in action {{action.actionNum}}": "Ta vrstica je bila pozneje odstranjena z akcijo {{action.actionNum}}"
},
"ApiKey": {
"Remove": "Odstrani",
"Create": "Ustvari",
"You're about to delete an API key. This will cause all future requests using this API key to be rejected. Do you still want to delete?": "Želite izbrisati ključ API. To bo povzročilo zavrnitev vseh prihodnjih zahtevkov, ki bodo uporabljali ta ključ API. Ali še vedno želite izbrisati?",
"Click to show": "Kliknite za prikaz",
"Remove API Key": "Odstranite API ključ",
"This API key can be used to access this account anonymously via the API.": "Ta API ključ lahko uporabite za anonimen dostop do tega računa prek vmesnika API."
},
"App": {
"Description": "Opis",
"Key": "Ključ",
"Memory Error": "Napaka pomnilnika"
},
"CellContextMenu": {
"Delete {{count}} columns_one": "Brisanje stolpca",
"Delete {{count}} columns_other": "Brisanje stolpcev {{count}}",
"Delete {{count}} rows_one": "Brisanje vrstice",
"Delete {{count}} rows_other": "Brisanje vrstic {{count}}",
"Filter by this value": "Filtriranje po tej vrednosti",
"Copy anchor link": "Kopiranje sidrne povezave",
"Duplicate rows_one": "Podvoji vrstico",
"Duplicate rows_other": "Podvoji vrstice",
"Insert column to the right": "Vstavljanje stolpca na desno",
"Insert column to the left": "Vstavljanje stolpca na levo",
"Insert row": "Vstavljanje vrstice",
"Insert row above": "Vstavite vrstico zgoraj",
"Insert row below": "Vstavite vrstico spodaj",
"Reset {{count}} columns_one": "Ponastavitev stolpca",
"Reset {{count}} columns_other": "Ponastavitev stolpcev {{count}}",
"Reset {{count}} entire columns_one": "Ponastavitev celotnega stolpca",
"Reset {{count}} entire columns_other": "Ponastavitev {{count}} stolpcev",
"Comment": "Komentar:",
"Copy": "Kopiraj",
"Cut": "Izreži",
"Paste": "Prilepi"
},
"DocMenu": {
"Document will be moved to Trash.": "Dokument se bo premaknil v koš.",
"Trash": "Koš",
"Rename": "Preimenuj",
"Delete": "Izbriši",
"Delete Forever": "Izbriši za vedno",
"Trash is empty.": "Koš je prazen.",
"You may delete a workspace forever once it has no documents in it.": "Delovni prostor lahko izbrišete za vedno, ko v njem ni več dokumentov.",
"Documents stay in Trash for 30 days, after which they get deleted permanently.": "Dokumenti ostanejo v košu 30 dni, nato pa se trajno izbrišejo.",
"Deleted {{at}}": "Izbrisano {{at}}",
"Delete {{name}}": "Izbriši {{name}}",
"Document will be permanently deleted.": "Dokument bo trajno izbrisan.",
"Permanently Delete \"{{name}}\"?": "Trajno izbrisati \"{{name}}\"?",
"(The organization needs a paid plan)": "(Organizacija potrebuje plačljiv načrt)",
"Access Details": "Podrobnosti o dostopu",
"All Documents": "Vsi dokumenti",
"By Date Modified": "Po datumu spremembe",
"By Name": "Po imenu",
"Current workspace": "Trenutni delovni prostor",
"Discover More Templates": "Odkrijte več predlog",
"Edited {{at}}": "Urejeno {{at}}",
"Examples and Templates": "Primeri in predloge",
"Featured": "Priporočeni",
"Manage Users": "Upravljanje uporabnikov",
"More Examples and Templates": "Več primerov in predlog"
},
"GridViewMenus": {
"Rename column": "Preimenovanje stolpca",
"Delete {{count}} columns_one": "Brisanje stolpca",
"Delete {{count}} columns_other": "Brisanje stolpcev {{count}}"
},
"HomeLeftPane": {
"Trash": "Koš",
"Rename": "Preimenovanje",
"Delete": "Izbriši",
"Delete {{workspace}} and all included documents?": "Izbriši {{workspace}} in vse vključene dokumente?"
},
"OnBoardingPopups": {
"Finish": "Zaključek",
"Next": "Naslednji"
},
"Pages": {
"Delete": "Izbriši",
"Delete data and this page.": "Izbriši podatke in to stran."
},
"RowContextMenu": {
"Delete": "Izbriši"
},
"Tools": {
"Delete": "Izbriši",
"Delete document tour?": "Izbriši ogled dokumenta?"
},
"pages": {
"Rename": "Preimenovanje"
},
"search": {
"Find Next ": "Poišči naslednjega "
},
"AddNewButton": {
"Add New": "Dodaj novo"
},
"DataTables": {
"Delete {{formattedTableName}} data, and remove it from all pages?": "Izbrišite podatke {{formattedTableName}} in jih odstranite z vseh strani?",
"Click to copy": "Kliknite za kopiranje",
"Duplicate Table": "Podvojena tabela",
"Table ID copied to clipboard": "ID tabele kopiran v odložišče",
"You do not have edit access to this document": "Nimate dostopa za urejanje tega dokumenta"
},
"ViewLayoutMenu": {
"Delete record": "Brisanje zapisa",
"Delete widget": "Izbriši gradnik"
},
"FieldEditor": {
"Unable to finish saving edited cell": "Ni mogoče dokončati shranjevanja urejene celice"
},
"AppHeader": {
"Home Page": "Domača stran",
"Personal Site": "Osebna stran",
"Team Site": "Spletna stran ekipe",
"Grist Templates": "Grist predloge"
},
"ChartView": {
"Pick a column": "Izberite stolpec"
},
"ColumnFilterMenu": {
"All": "Vse",
"All Except": "Vse razen",
"All Shown": "Vse prikazano",
"Future Values": "Prihodnje vrednosti",
"No matching values": "Ni ustreznih vrednosti",
"None": "Ni",
"Min": "Min",
"Max": "Max",
"Start": "Začetek",
"End": "Konec",
"Other Matching": "Drugo ujemanje",
"Other Non-Matching": "Drugo neujemanje",
"Other Values": "Druge vrednosti",
"Others": "Drugo",
"Search": "Iskanje",
"Search values": "Iskanje vrednosti"
},
"CustomSectionConfig": {
" (optional)": " (neobvezno)",
"Add": "Dodaj",
"Enter Custom URL": "Vnesite URL po meri",
"Full document access": "Dostop do celotnega dokumenta",
"Open configuration": "Odprri konfiguracijo",
"Pick a column": "Izberite stolpec",
"Pick a {{columnType}} column": "Izberite stolpec {{columnType}}",
"Read selected table": "Preberite izbrano tabelo",
"Learn more about custom widgets": "Preberite več o gradnikih po meri"
},
"DocHistory": {
"Activity": "Dejavnost",
"Beta": "Beta",
"Compare to Current": "Primerjava s trenutnim",
"Compare to Previous": "Primerjava s prejšnjimi",
"Snapshots": "Posnetki",
"Snapshots are unavailable.": "Posnetki niso na voljo."
},
"ExampleInfo": {
"Check out our related tutorial for how to link data, and create high-productivity layouts.": "Oglejte si sorodno navodilo za povezovanje podatkov in ustvarjanje visokoproduktivnih postavitev."
},
"CodeEditorPanel": {
"Access denied": "Dostop zavrnjen",
"Code View is available only when you have full document access.": "Pogled kode je na voljo le, če imate popoln dostop do dokumenta."
},
"ColorSelect": {
"Apply": "Uporabi",
"Cancel": "Prekliči",
"Default cell style": "Privzet slog celic"
}
}

View File

@ -14,7 +14,6 @@ import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import * as docUtils from 'app/server/lib/docUtils'; import * as docUtils from 'app/server/lib/docUtils';
import {FlexServer, FlexServerOptions} from 'app/server/lib/FlexServer'; import {FlexServer, FlexServerOptions} from 'app/server/lib/FlexServer';
import log from 'app/server/lib/log';
import {main as mergedServerMain, ServerType} from 'app/server/mergedServerMain'; import {main as mergedServerMain, ServerType} from 'app/server/mergedServerMain';
import axios from 'axios'; import axios from 'axios';
import FormData from 'form-data'; import FormData from 'form-data';
@ -287,7 +286,6 @@ export class TestSession {
fetch: fetch as any, fetch: fetch as any,
headers, headers,
newFormData: () => new FormData() as any, newFormData: () => new FormData() as any,
logger: log,
}); });
// Make sure api is functioning, and create user if this is their first time to hit API. // Make sure api is functioning, and create user if this is their first time to hit API.
if (checkAccess) { await api.getOrg('current'); } if (checkAccess) { await api.getOrg('current'); }

View File

@ -13,7 +13,6 @@ import {UserProfile} from 'app/common/LoginSessionAPI';
import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs'; import {BehavioralPrompt, UserPrefs, WelcomePopup} from 'app/common/Prefs';
import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; import {DocWorkerAPI, UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
import log from 'app/server/lib/log';
import {TestingHooksClient} from 'app/server/lib/TestingHooks'; import {TestingHooksClient} from 'app/server/lib/TestingHooks';
import EventEmitter = require('events'); import EventEmitter = require('events');
@ -445,7 +444,7 @@ export class HomeUtil {
headers, headers,
fetch: fetch as any, fetch: fetch as any,
newFormData: () => new FormData() as any, // form-data isn't quite type compatible newFormData: () => new FormData() as any, // form-data isn't quite type compatible
logger: log}); });
} }
private async _toggleTips(enabled: boolean, email: string) { private async _toggleTips(enabled: boolean, email: string) {

View File

@ -14,7 +14,6 @@ import {
getDocApiUsageKeysToIncr, getDocApiUsageKeysToIncr,
WebhookSubscription WebhookSubscription
} from 'app/server/lib/DocApi'; } from 'app/server/lib/DocApi';
import log from 'app/server/lib/log';
import {delayAbort} from 'app/server/lib/serverUtils'; import {delayAbort} from 'app/server/lib/serverUtils';
import axios, {AxiosRequestConfig, AxiosResponse} from 'axios'; import axios, {AxiosRequestConfig, AxiosResponse} from 'axios';
import {delay} from 'bluebird'; import {delay} from 'bluebird';
@ -875,6 +874,15 @@ function testDocApi() {
resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_Tables/data/delete`, resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_Tables/data/delete`,
[2, 3, 4, 5, 6], chimpy); [2, 3, 4, 5, 6], chimpy);
assert.equal(resp.status, 200); assert.equal(resp.status, 200);
// Despite deleting tables (even in a more official way than above),
// there are rules lingering relating to them. TODO: look into this.
resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_ACLRules/data/delete`,
[2, 3], chimpy);
assert.equal(resp.status, 200);
resp = await axios.post(`${serverUrl}/api/docs/${docIds.Timesheets}/tables/_grist_ACLResources/data/delete`,
[2, 3], chimpy);
assert.equal(resp.status, 200);
}); });
describe("/docs/{did}/tables/{tid}/columns", function () { describe("/docs/{did}/tables/{tid}/columns", function () {
@ -2485,7 +2493,6 @@ function testDocApi() {
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,
logger: log
}); });
// upload something for Chimpy and something else for Kiwi. // upload something for Chimpy and something else for Kiwi.
const worker1 = await userApi.getWorkerAPI('import'); const worker1 = await userApi.getWorkerAPI('import');
@ -2593,7 +2600,6 @@ function testDocApi() {
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,
logger: log
}); });
const ws2 = (await nasaApi.getOrgWorkspaces('current'))[0].id; const ws2 = (await nasaApi.getOrgWorkspaces('current'))[0].id;
const doc2 = await nasaApi.newDoc({name: 'testdoc2', urlId: 'urlid'}, ws2); const doc2 = await nasaApi.newDoc({name: 'testdoc2', urlId: 'urlid'}, ws2);
@ -2625,7 +2631,6 @@ function testDocApi() {
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,
logger: log
}); });
const ws2 = (await nasaApi.getOrgWorkspaces('current'))[0].id; const ws2 = (await nasaApi.getOrgWorkspaces('current'))[0].id;
const doc2 = await nasaApi.newDoc({name: 'testdoc2'}, ws2); const doc2 = await nasaApi.newDoc({name: 'testdoc2'}, ws2);
@ -3178,6 +3183,7 @@ function testDocApi() {
users: {"kiwi@getgrist.com": 'editors' as string | null} users: {"kiwi@getgrist.com": 'editors' as string | null}
}; };
let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
await flushAuth();
assert.equal(accessResp.status, 200); assert.equal(accessResp.status, 200);
const check = userCheck.bind(null, kiwi); const check = userCheck.bind(null, kiwi);
@ -3202,6 +3208,7 @@ function testDocApi() {
delta.users['kiwi@getgrist.com'] = null; delta.users['kiwi@getgrist.com'] = null;
accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
assert.equal(accessResp.status, 200); assert.equal(accessResp.status, 200);
await flushAuth();
}); });
it("DELETE /docs/{did}/tables/webhooks should not be allowed for not-owner", async function () { it("DELETE /docs/{did}/tables/webhooks should not be allowed for not-owner", async function () {
@ -3214,6 +3221,7 @@ function testDocApi() {
}; };
let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); let accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
assert.equal(accessResp.status, 200); assert.equal(accessResp.status, 200);
await flushAuth();
// Actually unsubscribe with the same unsubscribeKey that was returned by registration - it shouldn't be accepted // Actually unsubscribe with the same unsubscribeKey that was returned by registration - it shouldn't be accepted
await check(subscribeResponse.webhookId, 403, /No owner access/); await check(subscribeResponse.webhookId, 403, /No owner access/);
@ -3223,6 +3231,7 @@ function testDocApi() {
delta.users['kiwi@getgrist.com'] = null; delta.users['kiwi@getgrist.com'] = null;
accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy); accessResp = await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
assert.equal(accessResp.status, 200); assert.equal(accessResp.status, 200);
await flushAuth();
}); });
}); });
@ -4506,6 +4515,160 @@ function testDocApi() {
}); });
it ("GET /docs/{did}/sql is functional", async function () {
const query = 'select+*+from+Table1+order+by+id';
const resp = await axios.get(`${homeUrl}/api/docs/${docIds.Timesheets}/sql?q=${query}`, chimpy);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data, {
statement: 'select * from Table1 order by id',
records: [
{
fields: {
id: 1,
manualSort: 1,
A: 'hello',
B: '',
C: '',
D: null,
E: 'HELLO'
},
},
{
fields: { id: 2, manualSort: 2, A: '', B: 'world', C: '', D: null, E: '' }
},
{
fields: { id: 3, manualSort: 3, A: '', B: '', C: '', D: null, E: '' }
},
{
fields: { id: 4, manualSort: 4, A: '', B: '', C: '', D: null, E: '' }
},
]
});
});
it ("POST /docs/{did}/sql is functional", async function () {
let resp = await axios.post(
`${homeUrl}/api/docs/${docIds.Timesheets}/sql`,
{ sql: "select A from Table1 where id = ?", args: [ 1 ] },
chimpy);
assert.equal(resp.status, 200);
assert.deepEqual(resp.data.records, [{
fields: {
A: 'hello',
}
}]);
resp = await axios.post(
`${homeUrl}/api/docs/${docIds.Timesheets}/sql`,
{ nosql: "select A from Table1 where id = ?", args: [ 1 ] },
chimpy);
assert.equal(resp.status, 400);
assert.deepEqual(resp.data, {
error: 'Invalid payload',
details: { userError: 'Error: body.sql is missing' }
});
});
it ("POST /docs/{did}/sql has access control", async function () {
// Check non-viewer doesn't have access.
const url = `${homeUrl}/api/docs/${docIds.Timesheets}/sql`;
const query = { sql: "select A from Table1 where id = ?", args: [ 1 ] };
let resp = await axios.post(url, query, kiwi);
assert.equal(resp.status, 403);
assert.deepEqual(resp.data, {
error: 'No view access',
});
try {
// Check a viewer would have access.
const delta = {
users: { 'kiwi@getgrist.com': 'viewers' },
};
await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
await flushAuth();
resp = await axios.post(url, query, kiwi);
assert.equal(resp.status, 200);
// Check a viewer would not have access if there is some private material.
await axios.post(
`${homeUrl}/api/docs/${docIds.Timesheets}/apply`, [
['AddTable', 'TablePrivate', [{id: 'A', type: 'Int'}]],
['AddRecord', '_grist_ACLResources', -1, {tableId: 'TablePrivate', colIds: '*'}],
['AddRecord', '_grist_ACLRules', null, {
resource: -1, aclFormula: '', permissionsText: 'none',
}],
], chimpy);
resp = await axios.post(url, query, kiwi);
assert.equal(resp.status, 403);
} finally {
// Remove extra viewer; remove extra table.
const delta = {
users: { 'kiwi@getgrist.com': null },
};
await axios.patch(`${homeUrl}/api/docs/${docIds.Timesheets}/access`, {delta}, chimpy);
await flushAuth();
await axios.post(
`${homeUrl}/api/docs/${docIds.Timesheets}/apply`, [
['RemoveTable', 'TablePrivate'],
], chimpy);
}
});
it ("POST /docs/{did}/sql accepts only selects", async function () {
async function check(accept: boolean, sql: string, ...args: any[]) {
const resp = await axios.post(
`${homeUrl}/api/docs/${docIds.Timesheets}/sql`,
{ sql, args },
chimpy);
if (accept) {
assert.equal(resp.status, 200);
} else {
assert.equal(resp.status, 400);
}
return resp.data;
}
await check(true, 'select * from Table1');
await check(true, ' SeLeCT * from Table1');
await check(true, 'with results as (select 1) select * from results');
// rejected quickly since no select
await check(false, 'delete from Table1');
await check(false, '');
// rejected because deletes/updates/... can't be nested within a select
await check(false, "delete from Table1 where id in (select id from Table1) and 'selecty' = 'selecty'");
await check(false, "update Table1 set A = ? where 'selecty' = 'selecty'", 'test');
await check(false, "pragma data_store_directory = 'selecty'");
await check(false, "create table selecty(x, y)");
await check(false, "attach database 'selecty' AS test");
// rejected because ";" can't be nested
await check(false, 'select * from Table1; delete from Table1');
// Of course, we can get out of the wrapping select, but we can't
// add on more statements. For example, the following runs with no
// trouble - but only the SELECT part. The DELETE is discarded.
// (node-sqlite3 doesn't expose enough to give an error message for
// this, though we could extend it).
await check(true, 'select * from Table1); delete from Table1 where id in (select id from Table1');
const {records} = await check(true, 'select * from Table1');
// Double-check the deletion didn't happen.
assert.lengthOf(records, 4);
});
it ("POST /docs/{did}/sql timeout is effective", async function () {
const slowQuery = 'WITH RECURSIVE r(i) AS (VALUES(0) ' +
'UNION ALL SELECT i FROM r LIMIT 1000000) ' +
'SELECT i FROM r WHERE i = 1';
const resp = await axios.post(
`${homeUrl}/api/docs/${docIds.Timesheets}/sql`,
{ sql: slowQuery, timeout: 10 },
chimpy);
assert.equal(resp.status, 400);
assert.match(resp.data.error, /database interrupt/);
});
// PLEASE ADD MORE TESTS HERE // PLEASE ADD MORE TESTS HERE
} }
@ -4562,3 +4725,10 @@ async function setupDataDir(dir: string) {
'ApiDataRecordsTest.grist', 'ApiDataRecordsTest.grist',
path.resolve(dir, docIds.ApiDataRecordsTest + '.grist')); path.resolve(dir, docIds.ApiDataRecordsTest + '.grist'));
} }
// The access control level of a user on a document may be cached for a
// few seconds. This method flushes that cache.
async function flushAuth() {
await home.testingHooks.flushAuthorizerCache();
await docs.testingHooks.flushAuthorizerCache();
}

View File

@ -137,7 +137,6 @@ export class TestServer {
headers: {Authorization: `Bearer api_key_for_${user}`}, headers: {Authorization: `Bearer api_key_for_${user}`},
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,
logger: log
}); });
} }