(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:
Paul Fitzpatrick
2022-07-19 11:39:49 -04:00
parent 5c0a250309
commit dd8d2e18f5
22 changed files with 551 additions and 34 deletions

View 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}`;
}
}

View File

@@ -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.
*/

View File

@@ -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'};

View File

@@ -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';
}

View File

@@ -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});
}
/**

View File

@@ -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');
}
}
/**

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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() {

View File

@@ -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'); },
};
}

View File

@@ -35,6 +35,7 @@ export interface ICreate {
export interface ICreateActiveDocOptions {
safeMode?: boolean;
docUrl?: string;
docApiUrl?: string;
doc?: Document;
}