mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) add an access token mechanism to help with attachments in custom widgets
Summary:
With this, a custom widget can render an attachment by doing:
```
const tokenInfo = await grist.docApi.getAccessToken({readOnly: true});
const img = document.getElementById('the_image');
const id = record.C[0];  // get an id of an attachment
const src = `${tokenInfo.baseUrl}/attachments/${id}/download?auth=${tokenInfo.token}`;
img.setAttribute('src', src)
```
The access token expires after a few mins, so if a user right-clicks on an image
to save it, they may get access denied unless they refresh the page. A little awkward,
but s3 pre-authorized links behave similarly and it generally isn't a deal-breaker.
Test Plan: added tests
Reviewers: dsagal
Reviewed By: dsagal
Subscribers: dsagal
Differential Revision: https://phab.getgrist.com/D3488
			
			
This commit is contained in:
		
							parent
							
								
									5c0a250309
								
							
						
					
					
						commit
						dd8d2e18f5
					
				| @ -46,6 +46,7 @@ export class DocComm extends Disposable implements ActiveDocAPI { | ||||
|   public getAclResources = this._wrapMethod("getAclResources"); | ||||
|   public waitForInitialization = this._wrapMethod("waitForInitialization"); | ||||
|   public getUsersForViewAs = this._wrapMethod("getUsersForViewAs"); | ||||
|   public getAccessToken = this._wrapMethod("getAccessToken"); | ||||
| 
 | ||||
|   public changeUrlIdEmitter = this.autoDispose(new Emitter()); | ||||
| 
 | ||||
|  | ||||
| @ -6,7 +6,7 @@ import {AccessLevel, isSatisfied} from 'app/common/CustomWidget'; | ||||
| import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; | ||||
| import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions'; | ||||
| import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes'; | ||||
| import {CustomSectionAPI, GristDocAPI, GristView, | ||||
| import {AccessTokenOptions, CustomSectionAPI, GristDocAPI, GristView, | ||||
|         InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api'; | ||||
| import {MsgType, Rpc} from 'grain-rpc'; | ||||
| import {Computed, Disposable, dom, Observable} from 'grainjs'; | ||||
| @ -318,6 +318,21 @@ export class GristDocAPIImpl implements GristDocAPI { | ||||
|   public async applyUserActions(actions: any[][], options?: any) { | ||||
|     return this._doc.docComm.applyUserActions(actions, {desc: undefined, ...options}); | ||||
|   } | ||||
| 
 | ||||
|   // Get a token for out-of-band access to the document.
 | ||||
|   // Currently will require the custom widget to have full access to the
 | ||||
|   // document.
 | ||||
|   // It would be great to support this with read_table rights. This could be
 | ||||
|   // possible to do by adding a tableId setting to AccessTokenOptions,
 | ||||
|   // encoding that limitation in the access token, and ensuring the back-end
 | ||||
|   // respects it. But the current motivating use for adding access tokens is
 | ||||
|   // showing attachments, and they aren't currently something that logically
 | ||||
|   // lives within a specific table.
 | ||||
|   public async getAccessToken(options: AccessTokenOptions) { | ||||
|     return this._doc.docComm.getAccessToken({ | ||||
|       readOnly: options.readOnly, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -4,6 +4,7 @@ import {FormulaProperties} from 'app/common/GranularAccessClause'; | ||||
| import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; | ||||
| import {DocStateComparison, PermissionData, UserAccessData} from 'app/common/UserAPI'; | ||||
| import {ParseOptions} from 'app/plugin/FileParserAPI'; | ||||
| import {AccessTokenOptions, AccessTokenResult} from 'app/plugin/GristAPI'; | ||||
| import {IMessage} from 'grain-rpc'; | ||||
| 
 | ||||
| export interface ApplyUAOptions { | ||||
| @ -316,6 +317,11 @@ export interface ActiveDocAPI { | ||||
|    */ | ||||
|   checkAclFormula(text: string): Promise<FormulaProperties>; | ||||
| 
 | ||||
|   /** | ||||
|    * Get a token for out-of-band access to the document. | ||||
|    */ | ||||
|   getAccessToken(options: AccessTokenOptions): Promise<AccessTokenResult>; | ||||
| 
 | ||||
|   /** | ||||
|    * Returns the full set of tableIds, with the list of colIds for each table. This is intended | ||||
|    * for editing ACLs. It is only available to users who can edit ACLs, and lists all resources | ||||
|  | ||||
| @ -178,7 +178,12 @@ export function getOrgUrlInfo(newOrg: string, currentHost: string, options: OrgU | ||||
|  *    localhost:8080/o/<org> | ||||
|  */ | ||||
| export function encodeUrl(gristConfig: Partial<GristLoadConfig>, | ||||
|                           state: IGristUrlState, baseLocation: Location | URL): string { | ||||
|                           state: IGristUrlState, baseLocation: Location | URL, | ||||
|                           options: { | ||||
|                             // make an api url - warning: just barely works, and
 | ||||
|                             // only for documents
 | ||||
|                             api?: boolean | ||||
|                           } = {}): string { | ||||
|   const url = new URL(baseLocation.href); | ||||
|   const parts = ['/']; | ||||
| 
 | ||||
| @ -193,9 +198,14 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>, | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   if (options.api) { | ||||
|     parts.push(`api/`); | ||||
|   } | ||||
|   if (state.ws) { parts.push(`ws/${state.ws}/`); } | ||||
|   if (state.doc) { | ||||
|     if (state.slug) { | ||||
|     if (options.api) { | ||||
|       parts.push(`docs/${encodeURIComponent(state.doc)}`); | ||||
|     } else if (state.slug) { | ||||
|       parts.push(`${encodeURIComponent(state.doc)}/${encodeURIComponent(state.slug)}`); | ||||
|     } else { | ||||
|       parts.push(`doc/${encodeURIComponent(state.doc)}`); | ||||
|  | ||||
| @ -7,6 +7,7 @@ import { HomeDBManager } from "app/gen-server/lib/HomeDBManager"; | ||||
| import { assertAccess, getOrSetDocAuth, getTransitiveHeaders, RequestWithLogin } from 'app/server/lib/Authorizer'; | ||||
| import { IDocWorkerMap } from "app/server/lib/DocWorkerMap"; | ||||
| import { expressWrap } from "app/server/lib/expressWrap"; | ||||
| import { GristServer } from "app/server/lib/GristServer"; | ||||
| import { getAssignmentId } from "app/server/lib/idUtils"; | ||||
| 
 | ||||
| /** | ||||
| @ -25,7 +26,8 @@ import { getAssignmentId } from "app/server/lib/idUtils"; | ||||
|  */ | ||||
| export class DocApiForwarder { | ||||
| 
 | ||||
|   constructor(private _docWorkerMap: IDocWorkerMap, private _dbManager: HomeDBManager) { | ||||
|   constructor(private _docWorkerMap: IDocWorkerMap, private _dbManager: HomeDBManager, | ||||
|               private _gristServer: GristServer) { | ||||
|   } | ||||
| 
 | ||||
|   public addEndpoints(app: express.Application) { | ||||
| @ -61,7 +63,8 @@ export class DocApiForwarder { | ||||
|   ): Promise<void> { | ||||
|     let docId: string|null = null; | ||||
|     if (withDocId) { | ||||
|       const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, req.params.docId); | ||||
|       const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, | ||||
|                                             this._gristServer, req.params.docId); | ||||
|       if (role) { | ||||
|         assertAccess(role, docAuth, {allowRemoved: true}); | ||||
|       } | ||||
|  | ||||
| @ -137,8 +137,8 @@ class DummyDocWorkerMap implements IDocWorkerMap { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   public getRedisClient(): RedisClient { | ||||
|     throw new Error("No redis client here"); | ||||
|   public getRedisClient() { | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -18,6 +18,7 @@ export const GristDocAPI = t.iface([], { | ||||
|   "listTables": t.func(t.array("string")), | ||||
|   "fetchTable": t.func("any", t.param("tableId", "string")), | ||||
|   "applyUserActions": t.func("any", t.param("actions", t.array(t.array("any"))), t.param("options", "any", true)), | ||||
|   "getAccessToken": t.func("AccessTokenResult", t.param("options", "AccessTokenOptions")), | ||||
| }); | ||||
| 
 | ||||
| export const GristView = t.iface([], { | ||||
| @ -27,10 +28,22 @@ export const GristView = t.iface([], { | ||||
|   "setSelectedRows": t.func("void", t.param("rowIds", t.array("number"))), | ||||
| }); | ||||
| 
 | ||||
| export const AccessTokenOptions = t.iface([], { | ||||
|   "readOnly": t.opt("boolean"), | ||||
| }); | ||||
| 
 | ||||
| export const AccessTokenResult = t.iface([], { | ||||
|   "token": "string", | ||||
|   "baseUrl": "string", | ||||
|   "ttlMsecs": "number", | ||||
| }); | ||||
| 
 | ||||
| const exportedTypeSuite: t.ITypeSuite = { | ||||
|   ComponentKind, | ||||
|   GristAPI, | ||||
|   GristDocAPI, | ||||
|   GristView, | ||||
|   AccessTokenOptions, | ||||
|   AccessTokenResult, | ||||
| }; | ||||
| export default exportedTypeSuite; | ||||
|  | ||||
| @ -97,6 +97,11 @@ export interface GristDocAPI { | ||||
|   applyUserActions(actions: any[][], options?: any): Promise<any>; | ||||
|   // TODO: return type should be Promise<ApplyUAResult>, but this requires importing
 | ||||
|   // modules from `app/common` which is not currently supported by the build.
 | ||||
| 
 | ||||
|   /** | ||||
|    * Get a token for out-of-band access to the document. | ||||
|    */ | ||||
|   getAccessToken(options: AccessTokenOptions): Promise<AccessTokenResult>; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
| @ -127,3 +132,13 @@ export interface GristView { | ||||
|    */ | ||||
|   setSelectedRows(rowIds: number[]): Promise<void>; | ||||
| } | ||||
| 
 | ||||
| export interface AccessTokenOptions { | ||||
|   readOnly?: boolean;   // restrict use of token to reading.
 | ||||
| } | ||||
| 
 | ||||
| export interface AccessTokenResult { | ||||
|   token: string;        // token string
 | ||||
|   baseUrl: string;      // url of document api, like https://..../api/docs/DOCID
 | ||||
|   ttlMsecs: number;     // number of milliseconds token will be valid for (typically several minutes)
 | ||||
| } | ||||
|  | ||||
| @ -20,7 +20,8 @@ | ||||
| 
 | ||||
| import { ColumnsToMap, CustomSectionAPI, InteractionOptions, InteractionOptionsRequest, | ||||
|          WidgetColumnMap } from './CustomSectionAPI'; | ||||
| import { GristAPI, GristDocAPI, GristView, RPC_GRISTAPI_INTERFACE } from './GristAPI'; | ||||
| import { AccessTokenOptions, AccessTokenResult, GristAPI, GristDocAPI, | ||||
|          GristView, RPC_GRISTAPI_INTERFACE } from './GristAPI'; | ||||
| import { RowRecord } from './GristData'; | ||||
| import { ImportSource, ImportSourceAPI, InternalImportSourceAPI } from './InternalImportSourceAPI'; | ||||
| import { decodeObject, mapValues } from './objtypes'; | ||||
| @ -158,6 +159,14 @@ export function getTable(tableId?: string): TableOperations { | ||||
|   }, {}); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Get an access token, for making API calls outside of the custom widget | ||||
|  * API. There is no caching of tokens. | ||||
|  */ | ||||
| export async function getAccessToken(options?: AccessTokenOptions): Promise<AccessTokenResult> { | ||||
|   return docApi.getAccessToken(options || {}); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Get the current selected table (for custom widgets). | ||||
|  */ | ||||
|  | ||||
							
								
								
									
										267
									
								
								app/server/lib/AccessTokens.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								app/server/lib/AccessTokens.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,267 @@ | ||||
| import { ApiError } from 'app/common/ApiError'; | ||||
| import { MapWithTTL } from 'app/common/AsyncCreate'; | ||||
| import { KeyedMutex } from 'app/common/KeyedMutex'; | ||||
| import { AccessTokenOptions } from 'app/plugin/GristAPI'; | ||||
| import { makeId } from 'app/server/lib/idUtils'; | ||||
| import * as jwt from 'jsonwebtoken'; | ||||
| import { RedisClient } from 'redis'; | ||||
| 
 | ||||
| export const Deps = { | ||||
|   // Signed tokens expire after this length of time.
 | ||||
|   TOKEN_TTL_MSECS: 15 * 60 * 1000,  // 15 minutes.
 | ||||
|   MAX_SECRETS_KEPT: 3,   // Maximum number of secrets stored per doc.
 | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Non-optional information embedded in an access token. Currently | ||||
|  * access tokens are tied to an individual user and document. In | ||||
|  * future, they could be used outside of the context of a single | ||||
|  * document. | ||||
|  * | ||||
|  * Includes fields from AccessTokenOptions. | ||||
|  */ | ||||
| export interface AccessTokenInfo extends AccessTokenOptions { | ||||
|   userId: number; | ||||
|   docId: string; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Access token services. | ||||
|  */ | ||||
| export interface IAccessTokens { | ||||
|   /** | ||||
|    * Sign the content of an access token, returning a plain jwt-format | ||||
|    * string. A per-document secret will be used for signing. | ||||
|    */ | ||||
|   sign(content: AccessTokenInfo): Promise<string>; | ||||
| 
 | ||||
|   /** | ||||
|    * Read the content of a token, verifying its signature. | ||||
|    */ | ||||
|   verify(token: string): Promise<AccessTokenInfo>; | ||||
| 
 | ||||
|   /** | ||||
|    * Check how long access tokens remain valid, once minted. | ||||
|    */ | ||||
|   getNominalTTLInMsec(): number; | ||||
| 
 | ||||
|   close(): Promise<void>; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Implementation of access token services. Write operations should | ||||
|  * be done by a doc worker responsible for the document involved. | ||||
|  * Read operations can occur anywhere, such as home servers. | ||||
|  * This class has caches for _reads and _writes that are kept | ||||
|  * separate so that we don't have to reason about interactions | ||||
|  * between them. The class could be separated into two, one just | ||||
|  * for reading, and one just for writing. | ||||
|  * | ||||
|  * Token lifetime is handled by JWT expiration. Secret lifetime is | ||||
|  * handled by maintaining a rolling list of secrets (per document) | ||||
|  * that are replaced over time. | ||||
|  * | ||||
|  * Redis is used if available so that tokens issued by a worker will | ||||
|  * be honored by its replacement (within the token's period of validity). | ||||
|  * | ||||
|  * Secrets may last for a while. How long they last may vary with usage. | ||||
|  * A new secret is added when a local cache of signing secrets expires. | ||||
|  * Older secrets rotate out. For example, if we sign a token, then don't | ||||
|  * sign another until 0.9 * factor * TOKEN_TTL_MSECS later, a new token | ||||
|  * will used but the older one preserved. We could do the same | ||||
|  * MAX_SECRETS_KEPT-2 more times until the original secret is lost. | ||||
|  * This gives an overall lifetime of about factor * TOKEN_TTL_MSECS * MAX_SECRETS_KEPT. | ||||
|  * A secret could have a shorter lifetime of about factor * TOKEN_TTL_MSECS | ||||
|  * if we didn't sign anything else for a bit longer. So there's quite some | ||||
|  * variation, but the important thing is that secrets aren't lingering for | ||||
|  * many orders of magnitude more than the lifetime of the tokens they sign. | ||||
|  */ | ||||
| export class AccessTokens implements IAccessTokens { | ||||
|   private _store: IAccessTokenSignerStore;       // a redis or in-memory "back end".
 | ||||
|   private _reads: MapWithTTL<string, string[]>;  // a cache of recent reads.
 | ||||
|   private _writes: MapWithTTL<string, string[]>; // a cache of recent writes.
 | ||||
|   private _dtMsec: number;                       // the duration for which tokens must be honored.
 | ||||
|   private _mutex = new KeyedMutex();             // logic is simpler if serialized.
 | ||||
| 
 | ||||
|   // Use redis if available. Cache reads or writes for some multiple of the duration for which
 | ||||
|   // tokens must be honored. Cache is of a list of secrets. It is important to allow multiple
 | ||||
|   // secrets so we can change the secret we are signing with and still honor tokens signed with
 | ||||
|   // a previous secret.
 | ||||
|   constructor(cli: RedisClient|null, private _factor: number = 10) { | ||||
|     this._store = cli ? new RedisAccessTokenSignerStore(cli) : new InMemoryAccessTokenSignerStore(); | ||||
|     this._dtMsec = Deps.TOKEN_TTL_MSECS; | ||||
|     this._reads = new MapWithTTL<string, string[]>(this._dtMsec * _factor * 0.5); | ||||
|     this._writes = new MapWithTTL<string, string[]>(this._dtMsec * _factor * 0.5); | ||||
|   } | ||||
| 
 | ||||
|   // Return the duration we promise to honor a token for (although we may
 | ||||
|   // honor it for longer).
 | ||||
|   public getNominalTTLInMsec() { | ||||
|     return this._dtMsec; | ||||
|   } | ||||
| 
 | ||||
|   // Sign a token. We use JWT, and use its built-in expiration time.
 | ||||
|   public async sign(content: AccessTokenInfo): Promise<string> { | ||||
|     const encoder = await this._getOrCreateSecret(content.docId); | ||||
|     return jwt.sign(content, encoder, { expiresIn: this._dtMsec / 1000.0 }); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Check a token is valid. Since the secret used to sign it is dependent | ||||
|    * on the docId, we decode the token first to see what document it is claiming | ||||
|    * to be for. Then we try to verify the token with all the secrets known for | ||||
|    * that doc. Upon failure, we make sure the secret list is up to date and try | ||||
|    * again. There is room for optimizing here! | ||||
|    */ | ||||
|   public async verify(token: string): Promise<AccessTokenInfo> { | ||||
|     const content = jwt.decode(token); | ||||
|     if (typeof content !== 'object') { | ||||
|       throw new ApiError('Broken token', 401); | ||||
|     } | ||||
|     const docId = content?.docId as string; | ||||
|     if (typeof docId !== 'string' || !docId) { | ||||
|       throw new ApiError('Broken token', 401); | ||||
|     } | ||||
|     try { | ||||
|       // Try to verify with the secrets we already know about.
 | ||||
|       return await this._verifyWithGivenDoc(docId, token); | ||||
|     } catch (e) { | ||||
|       // Retry with up-to-date secrets.
 | ||||
|       await this._refreshSecrets(docId); | ||||
|       return await this._verifyWithGivenDoc(docId, token); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public async close() { | ||||
|     await this._store.close(); | ||||
|     this._reads.clear(); | ||||
|     this._writes.clear(); | ||||
|   } | ||||
| 
 | ||||
|   private async _verifyWithGivenDoc(docId: string, token: string): Promise<AccessTokenInfo> { | ||||
|     const secrets = this._reads.get(docId) || []; | ||||
|     for (const secret of secrets) { | ||||
|       try { | ||||
|         return this._verifyWithGivenSecret(secret, token); | ||||
|       } catch (e) { | ||||
|         if (String(e).match(/Token has expired/)) { | ||||
|           // Give specific error about token expiration.
 | ||||
|           throw e; | ||||
|         } | ||||
|         // continue, to try another secret.
 | ||||
|       } | ||||
|     } | ||||
|     throw new ApiError('Cannot verify token', 401); | ||||
|   } | ||||
| 
 | ||||
|   private _verifyWithGivenSecret(secret: string, token: string): AccessTokenInfo { | ||||
|     try { | ||||
|       const content: any = jwt.verify(token, secret); | ||||
|       if (typeof content !== 'object') { | ||||
|         throw new ApiError('Token mismatch', 401); | ||||
|       } | ||||
|       const userId = content.userId; | ||||
|       const docId = content.docId; | ||||
|       if (!userId) { throw new ApiError('no userId in access token', 401); } | ||||
|       if (!docId) { throw new ApiError('no docId in access token', 401); } | ||||
|       return content as AccessTokenInfo; | ||||
|     } catch (e) { | ||||
|       if (e.name === 'TokenExpiredError') { | ||||
|         throw new ApiError('Token has expired', 401); | ||||
|       } | ||||
|       throw new ApiError('Cannot verify token', 401); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get a secret to sign with. The secret needs to be | ||||
|    * valid for longer than dtMsec, so it is available | ||||
|    * for verifying the signed token throughout its | ||||
|    * lifetime. | ||||
|    * | ||||
|    * We maintain a truncated list of secrets, signing | ||||
|    * with the most recent, and verifying against any. | ||||
|    * | ||||
|    */ | ||||
|   private async _getOrCreateSecret(docId: string): Promise<string> { | ||||
|     return this._mutex.runExclusive(docId, async () => { | ||||
|       let secrets = this._writes.get(docId); | ||||
|       if (secrets && secrets.length >= 1) { | ||||
|         return secrets[0]; | ||||
|       } | ||||
|       // Our local cache of secrets to sign with is empty.
 | ||||
|       secrets = await this._store.getSigners(docId); | ||||
|       secrets.unshift(this._mintSecret()); | ||||
|       secrets.splice(Deps.MAX_SECRETS_KEPT); | ||||
|       this._writes.set(docId, secrets); | ||||
|       await this._store.setSigners(docId, secrets, this._dtMsec * this._factor); | ||||
|       return secrets[0]; | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   private async _refreshSecrets(docId: string): Promise<void> { | ||||
|     const inv = await this._store.getSigners(docId); | ||||
|     this._reads.set(docId, inv); | ||||
|   } | ||||
| 
 | ||||
|   private _mintSecret(): string { | ||||
|     return makeId() + makeId(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Store a list of signing secrets globally. Light wrapper over redis or memory. | ||||
|  */ | ||||
| export interface IAccessTokenSignerStore { | ||||
|   getSigners(docId: string): Promise<string[]>; | ||||
|   setSigners(docId: string, secret: string[], ttlMsec: number): Promise<void>; | ||||
|   close(): Promise<void>; | ||||
| } | ||||
| 
 | ||||
| // In-memory implementation of IAccessTokenSignerStore, usable for single-process Grist.
 | ||||
| // One limitation is that restarted processes won't honor tokens created by predecessor.
 | ||||
| export class InMemoryAccessTokenSignerStore implements IAccessTokenSignerStore { | ||||
|   private static _keys = new MapWithTTL<string, string[]>(Deps.TOKEN_TTL_MSECS); | ||||
|   private static _refCount: number = 0; | ||||
| 
 | ||||
|   public constructor() { | ||||
|     InMemoryAccessTokenSignerStore._refCount++; | ||||
|   } | ||||
| 
 | ||||
|   public async getSigners(docId: string): Promise<string[]> { | ||||
|     return InMemoryAccessTokenSignerStore._keys.get(docId) || []; | ||||
|   } | ||||
| 
 | ||||
|   public async setSigners(docId: string, secrets: string[], ttlMsec: number): Promise<void> { | ||||
|     InMemoryAccessTokenSignerStore._keys.setWithCustomTTL(docId, secrets, ttlMsec); | ||||
|   } | ||||
| 
 | ||||
|   public async close() { | ||||
|     InMemoryAccessTokenSignerStore._refCount--; | ||||
|     if (InMemoryAccessTokenSignerStore._refCount <= 0) { | ||||
|       InMemoryAccessTokenSignerStore._keys.clear(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Redis based implementation of IAccessTokenSignerStore, for multi process/instance
 | ||||
| // Grist.
 | ||||
| export class RedisAccessTokenSignerStore implements IAccessTokenSignerStore { | ||||
|   constructor(private _cli: RedisClient) { } | ||||
| 
 | ||||
|   public async getSigners(docId: string): Promise<string[]> { | ||||
|     const keys = await this._cli.getAsync(this._getKey(docId)); | ||||
|     return keys?.split(',') || []; | ||||
|   } | ||||
| 
 | ||||
|   public async setSigners(docId: string, secrets: string[], ttlMsec: number): Promise<void> { | ||||
|     await this._cli.setexAsync(this._getKey(docId), ttlMsec, secrets.join(',')); | ||||
|   } | ||||
| 
 | ||||
|   public async close() { | ||||
|   } | ||||
| 
 | ||||
|   private _getKey(docId: string) { | ||||
|     return `token-doc-decoder-${docId}`; | ||||
|   } | ||||
| } | ||||
| @ -59,6 +59,7 @@ import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccess | ||||
| import {parseUrlId} from 'app/common/gristUrls'; | ||||
| import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil'; | ||||
| import {InactivityTimer} from 'app/common/InactivityTimer'; | ||||
| import * as roles from 'app/common/roles'; | ||||
| import {schema, SCHEMA_VERSION} from 'app/common/schema'; | ||||
| import {MetaRowRecord, SingleCell} from 'app/common/TableData'; | ||||
| import {FetchUrlOptions, UploadResult} from 'app/common/uploads'; | ||||
| @ -67,7 +68,7 @@ import {convertFromColumn} from 'app/common/ValueConverter'; | ||||
| import {guessColInfoWithDocData} from 'app/common/ValueGuesser'; | ||||
| import {parseUserAction} from 'app/common/ValueParser'; | ||||
| import {ParseOptions} from 'app/plugin/FileParserAPI'; | ||||
| import {GristDocAPI} from 'app/plugin/GristAPI'; | ||||
| import {AccessTokenOptions, AccessTokenResult, GristDocAPI} from 'app/plugin/GristAPI'; | ||||
| import {compileAclFormula} from 'app/server/lib/ACLFormula'; | ||||
| import {Authorizer} from 'app/server/lib/Authorizer'; | ||||
| import {checksumFile} from 'app/server/lib/checksumFile'; | ||||
| @ -102,6 +103,7 @@ import {DocClients} from './DocClients'; | ||||
| import {DocPluginManager} from './DocPluginManager'; | ||||
| import { | ||||
|   DocSession, | ||||
|   getDocSessionAccess, | ||||
|   getDocSessionUser, | ||||
|   getDocSessionUserId, | ||||
|   makeExceptionalDocSession, | ||||
| @ -1350,6 +1352,33 @@ export class ActiveDoc extends EventEmitter { | ||||
|     return forkIds; | ||||
|   } | ||||
| 
 | ||||
|   public async getAccessToken(docSession: OptDocSession, options: AccessTokenOptions): Promise<AccessTokenResult> { | ||||
|     const tokens = this._docManager.gristServer.getAccessTokens(); | ||||
|     const userId = getDocSessionUserId(docSession); | ||||
|     const docId = this.docName; | ||||
|     const access = getDocSessionAccess(docSession); | ||||
|     // If we happen to be using a "readOnly" connection, max out at "readOnly"
 | ||||
|     // even if user could do more.
 | ||||
|     if (roles.getStrongestRole('viewers', access) === 'viewers') { | ||||
|       options.readOnly = true; | ||||
|     } | ||||
|     // Return a token that can be used to authorize as the given user.
 | ||||
|     if (!userId) { throw new Error('creating access token requires a user'); } | ||||
|     const token = await tokens.sign({ | ||||
|       readOnly: options.readOnly, | ||||
|       userId,  // definitely do not want userId overridable by options.
 | ||||
|       docId,   // likewise for docId.
 | ||||
|     }); | ||||
|     const ttlMsecs = tokens.getNominalTTLInMsec(); | ||||
|     const baseUrl = this._options?.docApiUrl; | ||||
|     if (!baseUrl) { throw new Error('cannot create token without URLs'); } | ||||
|     return { | ||||
|       token, | ||||
|       baseUrl, | ||||
|       ttlMsecs, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Check if an ACL formula is valid. If not, will throw an error with an explanation. | ||||
|    */ | ||||
|  | ||||
| @ -11,11 +11,13 @@ import {DocAuthKey, DocAuthResult, HomeDBManager} from 'app/gen-server/lib/HomeD | ||||
| import {forceSessionChange, getSessionProfiles, getSessionUser, getSignInStatus, linkOrgWithEmail, SessionObj, | ||||
|         SessionUserObj, SignInStatus} from 'app/server/lib/BrowserSession'; | ||||
| import {RequestWithOrg} from 'app/server/lib/extractOrg'; | ||||
| import {GristServer} from 'app/server/lib/GristServer'; | ||||
| import {COOKIE_MAX_AGE, getAllowedOrgForSessionID, getCookieDomain, | ||||
|         cookieName as sessionCookieName} from 'app/server/lib/gristSessions'; | ||||
| import {makeId} from 'app/server/lib/idUtils'; | ||||
| import log from 'app/server/lib/log'; | ||||
| import {IPermitStore, Permit} from 'app/server/lib/Permit'; | ||||
| import {AccessTokenInfo} from 'app/server/lib/AccessTokens'; | ||||
| import {allowHost, getOriginUrl, optStringParam} from 'app/server/lib/requestUtils'; | ||||
| import * as cookie from 'cookie'; | ||||
| import {NextFunction, Request, RequestHandler, Response} from 'express'; | ||||
| @ -34,6 +36,7 @@ export interface RequestWithLogin extends Request { | ||||
|   userIsAuthorized?: boolean;   // If userId is for "anonymous", this will be false.
 | ||||
|   docAuth?: DocAuthResult;      // For doc requests, the docId and the user's access level.
 | ||||
|   specialPermit?: Permit; | ||||
|   accessToken?: AccessTokenInfo; | ||||
|   altSessionId?: string;   // a session id for use in trigger formulas and granular access rules
 | ||||
|   activation?: ActivationState; | ||||
| } | ||||
| @ -143,6 +146,7 @@ export function getRequestProfile(req: Request|IncomingMessage, | ||||
|  */ | ||||
| export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPermitStore, | ||||
|                                      options: { | ||||
|                                        gristServer: GristServer, | ||||
|                                        skipSession?: boolean, | ||||
|                                        getProfile?(req: Request|IncomingMessage): Promise<UserProfile|null|undefined>, | ||||
|                                      }, | ||||
| @ -150,8 +154,27 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer | ||||
|   const mreq = req as RequestWithLogin; | ||||
|   let profile: UserProfile|undefined; | ||||
| 
 | ||||
|   // First, check for an apiKey
 | ||||
|   if (mreq.headers && mreq.headers.authorization) { | ||||
|   // We support multiple method of authentication. This flag gets set once
 | ||||
|   // we need not try any more. Specifically, it is used to avoid processing
 | ||||
|   // anything else after setting an access token, for simplicity in reasoning
 | ||||
|   // about this case.
 | ||||
|   let authDone: boolean = false; | ||||
| 
 | ||||
|   // Support providing an access token via an `auth` query parameter.
 | ||||
|   // This is useful for letting the browser load assets like image
 | ||||
|   // attachments.
 | ||||
|   const auth = optStringParam(mreq.query.auth); | ||||
|   if (auth) { | ||||
|     const tokens = options.gristServer.getAccessTokens(); | ||||
|     const token = await tokens.verify(auth); | ||||
|     mreq.accessToken = token; | ||||
|     // Once an accessToken is supplied, we don't consider anything else.
 | ||||
|     // User is treated as anonymous apart from having an accessToken.
 | ||||
|     authDone = true; | ||||
|   } | ||||
| 
 | ||||
|   // Now, check for an apiKey
 | ||||
|   if (!authDone && mreq.headers && mreq.headers.authorization) { | ||||
|     // header needs to be of form "Bearer XXXXXXXXX" to apply
 | ||||
|     const parts = String(mreq.headers.authorization).split(' '); | ||||
|     if (parts[0] === "Bearer") { | ||||
| @ -172,7 +195,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer | ||||
|   } | ||||
| 
 | ||||
|   // Special permission header for internal housekeeping tasks
 | ||||
|   if (mreq.headers && mreq.headers.permit) { | ||||
|   if (!authDone && mreq.headers && mreq.headers.permit) { | ||||
|     const permitKey = String(mreq.headers.permit); | ||||
|     try { | ||||
|       const permit = await permitStore.getPermit(permitKey); | ||||
| @ -193,13 +216,13 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer | ||||
|   //   https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#use-of-custom-request-headers
 | ||||
|   //   https://markitzeroday.com/x-requested-with/cors/2017/06/29/csrf-mitigation-for-ajax-requests.html
 | ||||
|   if (!mreq.userId && !mreq.xhr && !['GET', 'HEAD', 'OPTIONS'].includes(mreq.method)) { | ||||
|     return res.status(401).send('Bad request (missing header)'); | ||||
|     return res.status(401).json({error: 'Bad request (missing header)'}); | ||||
|   } | ||||
| 
 | ||||
|   // For some configurations, the user profile can be determined from the request.
 | ||||
|   // If this is the case, we won't use session information.
 | ||||
|   let skipSession: boolean = options.skipSession || false; | ||||
|   if (!mreq.userId) { | ||||
|   let skipSession: boolean = options.skipSession || authDone; | ||||
|   if (!authDone && !mreq.userId) { | ||||
|     let candidate = await options.getProfile?.(mreq); | ||||
|     if (candidate === undefined) { | ||||
|       candidate = getRequestProfile(mreq); | ||||
| @ -223,7 +246,7 @@ export async function addRequestUser(dbManager: HomeDBManager, permitStore: IPer | ||||
|   // for custom-host-specific sessionID.
 | ||||
|   let customHostSession = ''; | ||||
| 
 | ||||
|   if (!skipSession) { | ||||
|   if (!authDone && !skipSession) { | ||||
|     // If we haven't selected a user by other means, and have profiles available in the
 | ||||
|     // session, then select a user based on those profiles.
 | ||||
|     const session = mreq.session; | ||||
| @ -429,14 +452,38 @@ export function redirectToLogin( | ||||
|  * Sets mreq.docAuth if not yet set, and returns it. | ||||
|  */ | ||||
| export async function getOrSetDocAuth( | ||||
|   mreq: RequestWithLogin, dbManager: HomeDBManager, urlId: string | ||||
|   mreq: RequestWithLogin, dbManager: HomeDBManager, | ||||
|   gristServer: GristServer, | ||||
|   urlId: string | ||||
| ): Promise<DocAuthResult> { | ||||
|   if (!mreq.docAuth) { | ||||
|     let effectiveUserId = getUserId(mreq); | ||||
|     if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId()) { | ||||
|       effectiveUserId = dbManager.getPreviewerUserId(); | ||||
|     } | ||||
| 
 | ||||
|     // A permit with a token gives us the userId associated with that token.
 | ||||
|     const tokenObj = mreq.accessToken; | ||||
|     if (tokenObj) { | ||||
|       effectiveUserId = tokenObj.userId; | ||||
|     } | ||||
| 
 | ||||
|     mreq.docAuth = await dbManager.getDocAuthCached({urlId, userId: effectiveUserId, org: mreq.org}); | ||||
| 
 | ||||
|     if (tokenObj) { | ||||
|       // Sanity check: does the current document match the document the token is
 | ||||
|       // for? If not, fail.
 | ||||
|       if (!mreq.docAuth.docId || mreq.docAuth.docId !== tokenObj.docId) { | ||||
|         throw new ApiError('token misuse', 401); | ||||
|       } | ||||
|       // Limit access to read-only if specified.
 | ||||
|       if (tokenObj.readOnly) { | ||||
|         mreq.docAuth = {...mreq.docAuth, access: getWeakestRole('viewers', mreq.docAuth.access)}; | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // A permit with a user set to the anonymous user and linked to this document
 | ||||
|     // gets updated to full access.
 | ||||
|     if (mreq.specialPermit && mreq.userId === dbManager.getAnonymousUserId() && | ||||
|         mreq.specialPermit.docId === mreq.docAuth.docId) { | ||||
|       mreq.docAuth = {...mreq.docAuth, access: 'owners'}; | ||||
|  | ||||
| @ -890,7 +890,9 @@ export class DocWorkerApi { | ||||
| 
 | ||||
|     // Note the increased API usage on redis and in our local cache.
 | ||||
|     // Update redis in the background so that the rest of the request can continue without waiting for redis.
 | ||||
|     const multi = this._docWorkerMap.getRedisClient().multi(); | ||||
|     const cli = this._docWorkerMap.getRedisClient(); | ||||
|     if (!cli) { throw new Error('redis unexpectedly not available'); } | ||||
|     const multi = cli.multi(); | ||||
|     for (let i = 0; i < keys.length; i++) { | ||||
|       const key = keys[i]; | ||||
|       // Incrementing the local count immediately prevents many requests from being squeezed through every minute
 | ||||
| @ -922,7 +924,7 @@ export class DocWorkerApi { | ||||
|                               req: Request, res: Response, next: NextFunction) { | ||||
|     const scope = getDocScope(req); | ||||
|     allowRemoved = scope.showAll || scope.showRemoved || allowRemoved; | ||||
|     const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, scope.urlId); | ||||
|     const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, this._grist, scope.urlId); | ||||
|     if (role) { assertAccess(role, docAuth, {allowRemoved}); } | ||||
|     next(); | ||||
|   } | ||||
| @ -932,7 +934,7 @@ export class DocWorkerApi { | ||||
|    */ | ||||
|   private async _isOwner(req: Request) { | ||||
|     const scope = getDocScope(req); | ||||
|     const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, scope.urlId); | ||||
|     const docAuth = await getOrSetDocAuth(req as RequestWithLogin, this._dbManager, this._grist, scope.urlId); | ||||
|     return docAuth.access === 'owners'; | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -511,9 +511,12 @@ export class DocManager extends EventEmitter { | ||||
|     return await db.getRawDocById(docName); | ||||
|   } | ||||
| 
 | ||||
|   private async _getDocUrl(doc: Document) { | ||||
|   private async _getDocUrls(doc: Document) { | ||||
|     try { | ||||
|       return await this.gristServer.getResourceUrl(doc); | ||||
|       return { | ||||
|         docUrl: await this.gristServer.getResourceUrl(doc), | ||||
|         docApiUrl: await this.gristServer.getResourceUrl(doc, 'api'), | ||||
|       }; | ||||
|     } catch (e) { | ||||
|       // If there is no home url, we cannot construct links.  Accept this, for the benefit
 | ||||
|       // of legacy tests.
 | ||||
| @ -526,8 +529,8 @@ export class DocManager extends EventEmitter { | ||||
|   private async _createActiveDoc(docSession: OptDocSession, docName: string, safeMode?: boolean) { | ||||
|     const doc = await this._getDoc(docSession, docName); | ||||
|     // Get URL for document for use with SELF_HYPERLINK().
 | ||||
|     const docUrl = doc && await this._getDocUrl(doc); | ||||
|     return new ActiveDoc(this, docName, {docUrl, safeMode, doc}); | ||||
|     const docUrls = doc && await this._getDocUrls(doc); | ||||
|     return new ActiveDoc(this, docName, {...docUrls, safeMode, doc}); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|  | ||||
| @ -6,7 +6,7 @@ import { createRpcLogger, PluginInstance } from 'app/common/PluginInstance'; | ||||
| import { Promisified } from 'app/common/tpromisified'; | ||||
| import { ParseFileResult, ParseOptions } from 'app/plugin/FileParserAPI'; | ||||
| import { checkers, GristTable } from "app/plugin/grist-plugin-api"; | ||||
| import { GristDocAPI } from "app/plugin/GristAPI"; | ||||
| import { AccessTokenResult, GristDocAPI } from "app/plugin/GristAPI"; | ||||
| import { Storage } from 'app/plugin/StorageAPI'; | ||||
| import { ActiveDoc } from 'app/server/lib/ActiveDoc'; | ||||
| import { DocPluginData } from 'app/server/lib/DocPluginData'; | ||||
| @ -47,6 +47,13 @@ class GristDocAPIImpl implements GristDocAPI { | ||||
|   public applyUserActions(actions: any[][]): Promise<ApplyUAResult> { | ||||
|     return this._activeDoc.applyUserActions(makeExceptionalDocSession('plugin'), actions); | ||||
|   } | ||||
| 
 | ||||
|   // These implementations of GristDocAPI are from an early implementation of
 | ||||
|   // plugins that is incompatible with access control. No need to add new
 | ||||
|   // methods here.
 | ||||
|   public async getAccessToken(): Promise<AccessTokenResult> { | ||||
|     throw new Error('getAccessToken not implemented'); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -9,6 +9,7 @@ import {Client} from 'app/server/lib/Client'; | ||||
| import {Comm} from 'app/server/lib/Comm'; | ||||
| import {DocSession, docSessionFromRequest} from 'app/server/lib/DocSession'; | ||||
| import {filterDocumentInPlace} from 'app/server/lib/filterUtils'; | ||||
| import {GristServer} from 'app/server/lib/GristServer'; | ||||
| import {IDocStorageManager} from 'app/server/lib/IDocStorageManager'; | ||||
| import log from 'app/server/lib/log'; | ||||
| import {getDocId, integerParam, optStringParam, stringParam} from 'app/server/lib/requestUtils'; | ||||
| @ -21,12 +22,15 @@ import * as path from 'path'; | ||||
| 
 | ||||
| export interface AttachOptions { | ||||
|   comm: Comm;                             // Comm object for methods called via websocket
 | ||||
|   gristServer: GristServer; | ||||
| } | ||||
| 
 | ||||
| export class DocWorker { | ||||
|   private _comm: Comm; | ||||
|   constructor(private _dbManager: HomeDBManager, {comm}: AttachOptions) { | ||||
|     this._comm = comm; | ||||
|   private _gristServer: GristServer; | ||||
|   constructor(private _dbManager: HomeDBManager, options: AttachOptions) { | ||||
|     this._comm = options.comm; | ||||
|     this._gristServer = options.gristServer; | ||||
|   } | ||||
| 
 | ||||
|   public async getAttachment(req: express.Request, res: express.Response): Promise<void> { | ||||
| @ -121,6 +125,7 @@ export class DocWorker { | ||||
|       getAclResources:          activeDocMethod.bind(null, 'viewers', 'getAclResources'), | ||||
|       waitForInitialization:    activeDocMethod.bind(null, 'viewers', 'waitForInitialization'), | ||||
|       getUsersForViewAs:        activeDocMethod.bind(null, 'viewers', 'getUsersForViewAs'), | ||||
|       getAccessToken:           activeDocMethod.bind(null, 'viewers', 'getAccessToken'), | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
| @ -160,7 +165,7 @@ export class DocWorker { | ||||
|       } | ||||
|       if (!urlId) { return res.status(403).send({error: 'missing document id'}); } | ||||
| 
 | ||||
|       const docAuth = await getOrSetDocAuth(mreq, this._dbManager, urlId); | ||||
|       const docAuth = await getOrSetDocAuth(mreq, this._dbManager, this._gristServer, urlId); | ||||
|       assertAccess('viewers', docAuth); | ||||
|       next(); | ||||
|     } catch (err) { | ||||
|  | ||||
| @ -68,5 +68,5 @@ export interface IDocWorkerMap extends IPermitStores, IElectionStore, IChecksumS | ||||
|   getWorkerGroup(workerId: string): Promise<string|null>; | ||||
|   getDocGroup(docId: string): Promise<string|null>; | ||||
| 
 | ||||
|   getRedisClient(): RedisClient; | ||||
|   getRedisClient(): RedisClient|null; | ||||
| } | ||||
|  | ||||
| @ -16,6 +16,7 @@ import {getDocWorkerMap} from 'app/gen-server/lib/DocWorkerMap'; | ||||
| import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; | ||||
| import {Housekeeper} from 'app/gen-server/lib/Housekeeper'; | ||||
| import {Usage} from 'app/gen-server/lib/Usage'; | ||||
| import {AccessTokens, IAccessTokens} from 'app/server/lib/AccessTokens'; | ||||
| import {attachAppEndpoint} from 'app/server/lib/AppEndpoint'; | ||||
| import {appSettings} from 'app/server/lib/AppSettings'; | ||||
| import {addRequestUser, getUser, getUserId, isSingleUserMode, | ||||
| @ -125,6 +126,7 @@ export class FlexServer implements GristServer { | ||||
|   private _docWorkerMap: IDocWorkerMap; | ||||
|   private _widgetRepository: IWidgetRepository; | ||||
|   private _notifier: INotifier; | ||||
|   private _accessTokens: IAccessTokens; | ||||
|   private _internalPermitStore: IPermitStore;  // store for permits that stay within our servers
 | ||||
|   private _externalPermitStore: IPermitStore;  // store for permits that pass through outside servers
 | ||||
|   private _disabled: boolean = false; | ||||
| @ -303,6 +305,14 @@ export class FlexServer implements GristServer { | ||||
|     return this._notifier; | ||||
|   } | ||||
| 
 | ||||
|   public getAccessTokens() { | ||||
|     if (this._accessTokens) { return this._accessTokens; } | ||||
|     this.addDocWorkerMap(); | ||||
|     const cli = this._docWorkerMap.getRedisClient(); | ||||
|     this._accessTokens = new AccessTokens(cli); | ||||
|     return this._accessTokens; | ||||
|   } | ||||
| 
 | ||||
|   public sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void> { | ||||
|     if (!this._sendAppPage) { throw new Error('no _sendAppPage method available'); } | ||||
|     return this._sendAppPage(req, resp, options); | ||||
| @ -509,6 +519,7 @@ export class FlexServer implements GristServer { | ||||
|           getProfile: this._loginMiddleware.getProfile?.bind(this._loginMiddleware), | ||||
|             // Set this to false to stop Grist using a cookie for authentication purposes.
 | ||||
|           skipSession, | ||||
|           gristServer: this, | ||||
|         } | ||||
|       )); | ||||
|       this._trustOriginsMiddleware = expressWrap(trustOriginHandler); | ||||
| @ -625,6 +636,7 @@ export class FlexServer implements GristServer { | ||||
|     if (this.httpsServer) { this.httpsServer.close(); } | ||||
|     if (this.housekeeper) { await this.housekeeper.stop(); } | ||||
|     await this._shutdown(); | ||||
|     if (this._accessTokens) { await this._accessTokens.close(); } | ||||
|     // Do this after _shutdown, since DocWorkerMap is used during shutdown.
 | ||||
|     if (this._docWorkerMap) { await this._docWorkerMap.close(); } | ||||
|     if (this._sessionStore) { await this._sessionStore.close(); } | ||||
| @ -632,7 +644,7 @@ export class FlexServer implements GristServer { | ||||
| 
 | ||||
|   public addDocApiForwarder() { | ||||
|     if (this._check('doc_api_forwarder', '!json', 'homedb', 'api-mw', 'map')) { return; } | ||||
|     const docApiForwarder = new DocApiForwarder(this._docWorkerMap, this._dbManager); | ||||
|     const docApiForwarder = new DocApiForwarder(this._docWorkerMap, this._dbManager, this); | ||||
|     docApiForwarder.addEndpoints(this.app); | ||||
|   } | ||||
| 
 | ||||
| @ -1064,7 +1076,7 @@ export class FlexServer implements GristServer { | ||||
|     } | ||||
| 
 | ||||
|     // Attach docWorker endpoints and Comm methods.
 | ||||
|     const docWorker = new DocWorker(this._dbManager, {comm: this._comm}); | ||||
|     const docWorker = new DocWorker(this._dbManager, {comm: this._comm, gristServer: this}); | ||||
|     this._docWorker = docWorker; | ||||
| 
 | ||||
|     // Register the websocket comm functions associated with the docworker.
 | ||||
| @ -1299,7 +1311,8 @@ export class FlexServer implements GristServer { | ||||
|   /** | ||||
|    * Get a url for an organization, workspace, or document. | ||||
|    */ | ||||
|   public async getResourceUrl(resource: Organization|Workspace|Document): Promise<string> { | ||||
|   public async getResourceUrl(resource: Organization|Workspace|Document, | ||||
|                               purpose?: 'api'|'html'): Promise<string> { | ||||
|     if (!this._dbManager) { throw new Error('database missing'); } | ||||
|     const gristConfig = this.getGristConfig(); | ||||
|     const state: IGristUrlState = {}; | ||||
| @ -1316,7 +1329,8 @@ export class FlexServer implements GristServer { | ||||
|     } | ||||
|     state.org = this._dbManager.normalizeOrgDomain(org.id, org.domain, org.ownerId); | ||||
|     if (!gristConfig.homeUrl) { throw new Error('Computing a resource URL requires a home URL'); } | ||||
|     return encodeUrl(gristConfig, state, new URL(gristConfig.homeUrl)); | ||||
|     return encodeUrl(gristConfig, state, new URL(gristConfig.homeUrl), | ||||
|                      { api: purpose === 'api' }); | ||||
|   } | ||||
| 
 | ||||
|   public addUsage() { | ||||
|  | ||||
| @ -4,6 +4,7 @@ import { Document } from 'app/gen-server/entity/Document'; | ||||
| import { Organization } from 'app/gen-server/entity/Organization'; | ||||
| import { Workspace } from 'app/gen-server/entity/Workspace'; | ||||
| import { HomeDBManager } from 'app/gen-server/lib/HomeDBManager'; | ||||
| import { IAccessTokens } from 'app/server/lib/AccessTokens'; | ||||
| import { RequestWithLogin } from 'app/server/lib/Authorizer'; | ||||
| import { Comm } from 'app/server/lib/Comm'; | ||||
| import { create } from 'app/server/lib/create'; | ||||
| @ -31,7 +32,8 @@ export interface GristServer { | ||||
|   getOwnUrl(): string; | ||||
|   getOrgUrl(orgKey: string|number): Promise<string>; | ||||
|   getMergedOrgUrl(req: RequestWithLogin, pathname?: string): string; | ||||
|   getResourceUrl(resource: Organization|Workspace|Document): Promise<string>; | ||||
|   getResourceUrl(resource: Organization|Workspace|Document, | ||||
|                  purpose?: 'api'|'html'): Promise<string>; | ||||
|   getGristConfig(): GristLoadConfig; | ||||
|   getPermitStore(): IPermitStore; | ||||
|   getExternalPermitStore(): IPermitStore; | ||||
| @ -44,6 +46,7 @@ export interface GristServer { | ||||
|   getDocTemplate(): Promise<DocTemplate>; | ||||
|   getTag(): string; | ||||
|   sendAppPage(req: express.Request, resp: express.Response, options: ISendAppPageOptions): Promise<void>; | ||||
|   getAccessTokens(): IAccessTokens; | ||||
| } | ||||
| 
 | ||||
| export interface GristLoginSystem { | ||||
| @ -117,5 +120,6 @@ export function createDummyGristServer(): GristServer { | ||||
|     getDocTemplate() { throw new Error('no doc template'); }, | ||||
|     getTag() { return 'tag'; }, | ||||
|     sendAppPage() { return Promise.resolve(); }, | ||||
|     getAccessTokens() { throw new Error('no access tokens'); }, | ||||
|   }; | ||||
| } | ||||
|  | ||||
| @ -35,6 +35,7 @@ export interface ICreate { | ||||
| export interface ICreateActiveDocOptions { | ||||
|   safeMode?: boolean; | ||||
|   docUrl?: string; | ||||
|   docApiUrl?: string; | ||||
|   doc?: Document; | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -41,6 +41,7 @@ | ||||
|     "@types/fs-extra": "5.0.4", | ||||
|     "@types/image-size": "0.0.29", | ||||
|     "@types/js-yaml": "3.11.2", | ||||
|     "@types/jsonwebtoken": "7.2.8", | ||||
|     "@types/lodash": "4.14.117", | ||||
|     "@types/lru-cache": "5.1.1", | ||||
|     "@types/mime-types": "2.1.0", | ||||
| @ -119,6 +120,7 @@ | ||||
|     "image-size": "0.6.3", | ||||
|     "jquery": "2.2.1", | ||||
|     "js-yaml": "3.12.0", | ||||
|     "jsonwebtoken": "8.3.0", | ||||
|     "knockout": "3.5.0", | ||||
|     "locale-currency": "0.0.2", | ||||
|     "lodash": "4.17.15", | ||||
|  | ||||
							
								
								
									
										64
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										64
									
								
								yarn.lock
									
									
									
									
									
								
							| @ -391,6 +391,13 @@ | ||||
|   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" | ||||
|   integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== | ||||
| 
 | ||||
| "@types/jsonwebtoken@7.2.8": | ||||
|   version "7.2.8" | ||||
|   resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-7.2.8.tgz#8d199dab4ddb5bba3234f8311b804d2027af2b3a" | ||||
|   integrity sha512-XENN3YzEB8D6TiUww0O8SRznzy1v+77lH7UmuN54xq/IHIsyWjWOzZuFFTtoiRuaE782uAoRwBe/wwow+vQXZw== | ||||
|   dependencies: | ||||
|     "@types/node" "*" | ||||
| 
 | ||||
| "@types/lodash@4.14.117": | ||||
|   version "4.14.117" | ||||
|   resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.117.tgz#695a7f514182771a1e0f4345d189052ee33c8778" | ||||
| @ -3901,6 +3908,21 @@ jsonparse@^1.2.0: | ||||
|   resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" | ||||
|   integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= | ||||
| 
 | ||||
| jsonwebtoken@8.3.0: | ||||
|   version "8.3.0" | ||||
|   resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz#056c90eee9a65ed6e6c72ddb0a1d325109aaf643" | ||||
|   integrity sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag== | ||||
|   dependencies: | ||||
|     jws "^3.1.5" | ||||
|     lodash.includes "^4.3.0" | ||||
|     lodash.isboolean "^3.0.3" | ||||
|     lodash.isinteger "^4.0.4" | ||||
|     lodash.isnumber "^3.0.3" | ||||
|     lodash.isplainobject "^4.0.6" | ||||
|     lodash.isstring "^4.0.1" | ||||
|     lodash.once "^4.0.0" | ||||
|     ms "^2.1.1" | ||||
| 
 | ||||
| jsprim@^1.2.2: | ||||
|   version "1.4.1" | ||||
|   resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" | ||||
| @ -3926,6 +3948,15 @@ just-extend@^4.0.2: | ||||
|   resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744" | ||||
|   integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg== | ||||
| 
 | ||||
| jwa@^1.4.1: | ||||
|   version "1.4.1" | ||||
|   resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" | ||||
|   integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== | ||||
|   dependencies: | ||||
|     buffer-equal-constant-time "1.0.1" | ||||
|     ecdsa-sig-formatter "1.0.11" | ||||
|     safe-buffer "^5.0.1" | ||||
| 
 | ||||
| jwa@^2.0.0: | ||||
|   version "2.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" | ||||
| @ -3935,6 +3966,14 @@ jwa@^2.0.0: | ||||
|     ecdsa-sig-formatter "1.0.11" | ||||
|     safe-buffer "^5.0.1" | ||||
| 
 | ||||
| jws@^3.1.5: | ||||
|   version "3.2.2" | ||||
|   resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" | ||||
|   integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== | ||||
|   dependencies: | ||||
|     jwa "^1.4.1" | ||||
|     safe-buffer "^5.0.1" | ||||
| 
 | ||||
| jws@^4.0.0: | ||||
|   version "4.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" | ||||
| @ -4072,6 +4111,11 @@ lodash.groupby@^4.6.0: | ||||
|   resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1" | ||||
|   integrity sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E= | ||||
| 
 | ||||
| lodash.includes@^4.3.0: | ||||
|   version "4.3.0" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" | ||||
|   integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w== | ||||
| 
 | ||||
| lodash.isboolean@^3.0.3: | ||||
|   version "3.0.3" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" | ||||
| @ -4087,16 +4131,31 @@ lodash.isfunction@^3.0.9: | ||||
|   resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz#06de25df4db327ac931981d1bdb067e5af68d051" | ||||
|   integrity sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw== | ||||
| 
 | ||||
| lodash.isinteger@^4.0.4: | ||||
|   version "4.0.4" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" | ||||
|   integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA== | ||||
| 
 | ||||
| lodash.isnil@^4.0.0: | ||||
|   version "4.0.0" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c" | ||||
|   integrity sha1-SeKM1VkBNFjIFMVHnTxmOiG/qmw= | ||||
| 
 | ||||
| lodash.isnumber@^3.0.3: | ||||
|   version "3.0.3" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" | ||||
|   integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw== | ||||
| 
 | ||||
| lodash.isplainobject@^4.0.6: | ||||
|   version "4.0.6" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" | ||||
|   integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= | ||||
| 
 | ||||
| lodash.isstring@^4.0.1: | ||||
|   version "4.0.1" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" | ||||
|   integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== | ||||
| 
 | ||||
| lodash.isundefined@^3.0.1: | ||||
|   version "3.0.1" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz#23ef3d9535565203a66cefd5b830f848911afb48" | ||||
| @ -4107,6 +4166,11 @@ lodash.memoize@~3.0.3: | ||||
|   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-3.0.4.tgz#2dcbd2c287cbc0a55cc42328bd0c736150d53e3f" | ||||
|   integrity sha1-LcvSwofLwKVcxCMovQxzYVDVPj8= | ||||
| 
 | ||||
| lodash.once@^4.0.0: | ||||
|   version "4.1.1" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" | ||||
|   integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== | ||||
| 
 | ||||
| lodash.union@^4.6.0: | ||||
|   version "4.6.0" | ||||
|   resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user