mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) start reconciling forking with granular access
Summary: This allows a fork to be made by a user if: * That user is an owner of the document being forked, or * That user has full read access to the document being forked. The bulk of the diff is reorganization of how forking is done. ActiveDoc.fork is now responsible for creating a fork, not just a docId/urlId for the fork. Since fork creation should not be limited to the doc worker hosting the trunk, a helper endpoint is added for placing the fork. The change required sanitizing worker allocation a bit, and allowed session knowledge to be removed from HostedStorageManager. Test Plan: Added test; existing tests pass. Reviewers: dsagal Reviewed By: dsagal Differential Revision: https://phab.getgrist.com/D2700
This commit is contained in:
		
							parent
							
								
									68a682f876
								
							
						
					
					
						commit
						438f259687
					
				@ -99,6 +99,7 @@ export class KeyedOps {
 | 
			
		||||
      await status.promise;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (!this._changed.has(key)) { return; }
 | 
			
		||||
    const callback = new Promise((resolve) => {
 | 
			
		||||
      status.callbacks.push(resolve);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -42,6 +42,8 @@ export class DocApiForwarder {
 | 
			
		||||
    app.use('/api/docs/:docId/remove', withDoc);
 | 
			
		||||
    app.delete('/api/docs/:docId', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/download', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/fork', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/create-fork', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/apply', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/attachments', withDoc);
 | 
			
		||||
    app.use('/api/docs/:docId/snapshots', withDoc);
 | 
			
		||||
 | 
			
		||||
@ -328,8 +328,29 @@ export class DocWorkerMap implements IDocWorkerMap {
 | 
			
		||||
   * A preferred doc worker can be specified, which will be assigned
 | 
			
		||||
   * if no assignment is already made.
 | 
			
		||||
   *
 | 
			
		||||
   * For the special docId "import", return a potential assignment.
 | 
			
		||||
   * It will be up to the doc worker to assign the eventually
 | 
			
		||||
   * created document, if desired.
 | 
			
		||||
   *
 | 
			
		||||
   */
 | 
			
		||||
  public async assignDocWorker(docId: string, workerId?: string): Promise<DocStatus> {
 | 
			
		||||
    if (docId === 'import') {
 | 
			
		||||
      const lock = await this._redlock.lock(`workers-lock`, LOCK_TIMEOUT);
 | 
			
		||||
      try {
 | 
			
		||||
        const workerId = await this._client.srandmemberAsync(`workers-available-default`);
 | 
			
		||||
        if (!workerId) { throw new Error('no doc worker available'); }
 | 
			
		||||
        const docWorker = await this._client.hgetallAsync(`worker-${workerId}`) as DocWorkerInfo|null;
 | 
			
		||||
        if (!docWorker) { throw new Error('no doc worker contact info available'); }
 | 
			
		||||
        return {
 | 
			
		||||
          docMD5: null,
 | 
			
		||||
          docWorker,
 | 
			
		||||
          isActive: false
 | 
			
		||||
        };
 | 
			
		||||
      } finally {
 | 
			
		||||
        await lock.unlock();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check if a DocWorker is already assigned; if so return result immediately
 | 
			
		||||
    // without locking.
 | 
			
		||||
    let docStatus = await this.getDocWorker(docId);
 | 
			
		||||
 | 
			
		||||
@ -52,13 +52,14 @@ import {ActionHistoryImpl} from './ActionHistoryImpl';
 | 
			
		||||
import {ActiveDocImport} from './ActiveDocImport';
 | 
			
		||||
import {DocClients} from './DocClients';
 | 
			
		||||
import {DocPluginManager} from './DocPluginManager';
 | 
			
		||||
import {DocSession, getDocSessionAccess, getDocSessionUserId, makeExceptionalDocSession,
 | 
			
		||||
        OptDocSession} from './DocSession';
 | 
			
		||||
import {DocSession, getDocSessionAccess, getDocSessionUser, getDocSessionUserId,
 | 
			
		||||
        makeExceptionalDocSession, OptDocSession} from './DocSession';
 | 
			
		||||
import {DocStorage} from './DocStorage';
 | 
			
		||||
import {expandQuery} from './ExpandedQuery';
 | 
			
		||||
import {GranularAccess} from './GranularAccess';
 | 
			
		||||
import {OnDemandActions} from './OnDemandActions';
 | 
			
		||||
import {findOrAddAllEnvelope, Sharing} from './Sharing';
 | 
			
		||||
import fetch from 'node-fetch';
 | 
			
		||||
 | 
			
		||||
bluebird.promisifyAll(tmp);
 | 
			
		||||
 | 
			
		||||
@ -328,8 +329,7 @@ export class ActiveDoc extends EventEmitter {
 | 
			
		||||
    const startTime = Date.now();
 | 
			
		||||
    this.logDebug(docSession, "loadDoc");
 | 
			
		||||
    try {
 | 
			
		||||
      const isNew: boolean = await this._docManager.storageManager.prepareLocalDoc(this.docName,
 | 
			
		||||
                                                                                   docSession);
 | 
			
		||||
      const isNew: boolean = await this._docManager.storageManager.prepareLocalDoc(this.docName);
 | 
			
		||||
      if (isNew) {
 | 
			
		||||
        await this.createDoc(docSession);
 | 
			
		||||
        await this.addInitialTable(docSession);
 | 
			
		||||
@ -571,8 +571,7 @@ export class ActiveDoc extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
  // Check if user has rights to download this doc.
 | 
			
		||||
  public async canDownload(docSession: OptDocSession) {
 | 
			
		||||
    return this._granularAccess.hasViewAccess(docSession) &&
 | 
			
		||||
      await this._granularAccess.canReadEverything(docSession);
 | 
			
		||||
    return this._granularAccess.canCopyEverything(docSession);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
@ -885,19 +884,47 @@ export class ActiveDoc extends EventEmitter {
 | 
			
		||||
   * Fork the current document.  In fact, all that requires is calculating a good
 | 
			
		||||
   * ID for the fork.  TODO: reconcile the two ways there are now of preparing a fork.
 | 
			
		||||
   */
 | 
			
		||||
  public async fork(docSession: DocSession): Promise<ForkResult> {
 | 
			
		||||
    if (!await this._granularAccess.canReadEverything(docSession)) {
 | 
			
		||||
      throw new Error('cannot confirm authority to copy document');
 | 
			
		||||
  public async fork(docSession: OptDocSession): Promise<ForkResult> {
 | 
			
		||||
    const user = getDocSessionUser(docSession);
 | 
			
		||||
    // For now, fork only if user can read everything (or is owner).
 | 
			
		||||
    // TODO: allow forks with partial content.
 | 
			
		||||
    if (!user || !await this._granularAccess.canCopyEverything(docSession)) {
 | 
			
		||||
      throw new ApiError('Insufficient access to document to copy it entirely', 403);
 | 
			
		||||
    }
 | 
			
		||||
    const userId = docSession.client.getCachedUserId();
 | 
			
		||||
    const isAnonymous = docSession.client.isAnonymous();
 | 
			
		||||
    const userId = user.id;
 | 
			
		||||
    const isAnonymous = this._docManager.isAnonymous(userId);
 | 
			
		||||
    // Get fresh document metadata (the cached metadata doesn't include the urlId).
 | 
			
		||||
    const doc = await docSession.authorizer.getDoc();
 | 
			
		||||
    const doc = await docSession.authorizer?.getDoc();
 | 
			
		||||
    if (!doc) { throw new Error('document id not known'); }
 | 
			
		||||
    const trunkDocId = doc.id;
 | 
			
		||||
    const trunkUrlId = doc.urlId || doc.id;
 | 
			
		||||
    await this.flushDoc();  // Make sure fork won't be too out of date.
 | 
			
		||||
    return makeForkIds({userId, isAnonymous, trunkDocId, trunkUrlId});
 | 
			
		||||
    const forkIds = makeForkIds({userId, isAnonymous, trunkDocId, trunkUrlId});
 | 
			
		||||
 | 
			
		||||
    // To actually create the fork, we call an endpoint.  This is so the fork
 | 
			
		||||
    // can be associated with an arbitrary doc worker, rather than tied to the
 | 
			
		||||
    // same worker as the trunk.  We use a Permit for authorization.
 | 
			
		||||
    const permitStore = this._docManager.gristServer.getPermitStore();
 | 
			
		||||
    const permitKey = await permitStore.setPermit({docId: forkIds.docId,
 | 
			
		||||
                                                   otherDocId: this.docName});
 | 
			
		||||
    try {
 | 
			
		||||
      const url = await this._docManager.gristServer.getHomeUrlByDocId(forkIds.docId, `/api/docs/${forkIds.docId}/create-fork`);
 | 
			
		||||
      const resp = await fetch(url, {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        body: JSON.stringify({ srcDocId: this.docName }),
 | 
			
		||||
        headers: {
 | 
			
		||||
          Permit: permitKey,
 | 
			
		||||
          'Content-Type': 'application/json',
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
      if (resp.status !== 200) {
 | 
			
		||||
        throw new ApiError(resp.statusText, resp.status);
 | 
			
		||||
      }
 | 
			
		||||
    } finally {
 | 
			
		||||
      await permitStore.removePermit(permitKey);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return forkIds;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,7 @@ import {BrowserSettings} from 'app/common/BrowserSettings';
 | 
			
		||||
import {ErrorWithCode} from 'app/common/ErrorWithCode';
 | 
			
		||||
import {UserProfile} from 'app/common/LoginSessionAPI';
 | 
			
		||||
import {getLoginState, LoginState} from 'app/common/LoginState';
 | 
			
		||||
import {ANONYMOUS_USER_EMAIL} from 'app/common/UserAPI';
 | 
			
		||||
import {User} from 'app/gen-server/entity/User';
 | 
			
		||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
 | 
			
		||||
import {ActiveDoc} from 'app/server/lib/ActiveDoc';
 | 
			
		||||
@ -297,10 +298,17 @@ export class Client {
 | 
			
		||||
    // practice via a change to profile, but let's not make any assumptions here.)
 | 
			
		||||
    this._userId = null;
 | 
			
		||||
    this._firstLoginAt = null;
 | 
			
		||||
    this._isAnonymous = false;
 | 
			
		||||
    this._isAnonymous = !profile;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getProfile(): UserProfile|null {
 | 
			
		||||
    if (this._isAnonymous) {
 | 
			
		||||
      return {
 | 
			
		||||
        name: 'Anonymous',
 | 
			
		||||
        email: ANONYMOUS_USER_EMAIL,
 | 
			
		||||
        anonymous: true,
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
    return this._profile;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -315,10 +323,16 @@ export class Client {
 | 
			
		||||
  // Returns the userId for profile.email, or null when profile is not set; with caching.
 | 
			
		||||
  public async getUserId(dbManager: HomeDBManager): Promise<number|null> {
 | 
			
		||||
    if (!this._userId) {
 | 
			
		||||
      const user = await this._fetchUser(dbManager);
 | 
			
		||||
      this._userId = (user && user.id) || null;
 | 
			
		||||
      this._isAnonymous = this._userId && dbManager.getAnonymousUserId() === this._userId || false;
 | 
			
		||||
      this._firstLoginAt = (user && user.firstLoginAt) || null;
 | 
			
		||||
      if (this._profile) {
 | 
			
		||||
        const user = await this._fetchUser(dbManager);
 | 
			
		||||
        this._userId = (user && user.id) || null;
 | 
			
		||||
        this._isAnonymous = this._userId && dbManager.getAnonymousUserId() === this._userId || false;
 | 
			
		||||
        this._firstLoginAt = (user && user.firstLoginAt) || null;
 | 
			
		||||
      } else {
 | 
			
		||||
        this._userId = dbManager.getAnonymousUserId();
 | 
			
		||||
        this._isAnonymous = true;
 | 
			
		||||
        this._firstLoginAt = null;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    return this._userId;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -207,6 +207,22 @@ export class DocWorkerApi {
 | 
			
		||||
      }
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    // Fork the specified document.
 | 
			
		||||
    this._app.post('/api/docs/:docId/fork', canView, withDoc(async (activeDoc, req, res) => {
 | 
			
		||||
      const result = await activeDoc.fork(docSessionFromRequest(req));
 | 
			
		||||
      res.json(result);
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    // Initiate a fork.  Used internally to implement ActiveDoc.fork.  Only usable via a Permit.
 | 
			
		||||
    this._app.post('/api/docs/:docId/create-fork', canEdit, throttled(async (req, res) => {
 | 
			
		||||
      const mreq = req as RequestWithLogin;
 | 
			
		||||
      const docId = stringParam(req.params.docId);
 | 
			
		||||
      const srcDocId = stringParam(req.body.srcDocId);
 | 
			
		||||
      if (srcDocId !== mreq.specialPermit?.otherDocId) { throw new Error('access denied'); }
 | 
			
		||||
      await this._docManager.storageManager.prepareFork(srcDocId, docId);
 | 
			
		||||
      res.json({srcDocId, docId});
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    // Update records. The records to update are identified by their id column. Any invalid id fails
 | 
			
		||||
    // the request and returns a 400 error code.
 | 
			
		||||
    this._app.patch('/api/docs/:docId/tables/:tableId/data', canEdit, withDoc(async (activeDoc, req, res) => {
 | 
			
		||||
@ -458,7 +474,7 @@ export class DocWorkerApi {
 | 
			
		||||
      const isAnonymous = isAnonymousUser(req);
 | 
			
		||||
      const {docId} = makeForkIds({userId, isAnonymous, trunkDocId: NEW_DOCUMENT_CODE,
 | 
			
		||||
                                   trunkUrlId: NEW_DOCUMENT_CODE});
 | 
			
		||||
      await this._docManager.fetchDoc(makeExceptionalDocSession('nascent', {
 | 
			
		||||
      await this._docManager.createNamedDoc(makeExceptionalDocSession('nascent', {
 | 
			
		||||
        req: req as RequestWithLogin,
 | 
			
		||||
        browserSettings
 | 
			
		||||
      }), docId);
 | 
			
		||||
 | 
			
		||||
@ -109,7 +109,11 @@ export class DocManager extends EventEmitter {
 | 
			
		||||
  public async createNewDoc(client: Client): Promise<string> {
 | 
			
		||||
    log.debug('DocManager.createNewDoc');
 | 
			
		||||
    const docSession = makeExceptionalDocSession('nascent', {client});
 | 
			
		||||
    const activeDoc: ActiveDoc = await this.createNewEmptyDoc(docSession, 'Untitled');
 | 
			
		||||
    return this.createNamedDoc(docSession, 'Untitled');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async createNamedDoc(docSession: OptDocSession, docId: string): Promise<string> {
 | 
			
		||||
    const activeDoc: ActiveDoc = await this.createNewEmptyDoc(docSession, docId);
 | 
			
		||||
    await activeDoc.addInitialTable(docSession);
 | 
			
		||||
    return activeDoc.docName;
 | 
			
		||||
  }
 | 
			
		||||
@ -188,9 +192,10 @@ export class DocManager extends EventEmitter {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Ship the import to S3, since it isn't associated with any particular worker at this time.
 | 
			
		||||
    // We could associate it with the current worker, but that is not necessarily desirable.
 | 
			
		||||
    await this.storageManager.addToStorage(result.id);
 | 
			
		||||
    // The imported document is associated with the worker that did the import.
 | 
			
		||||
    // We could break that association (see /api/docs/:docId/assign for how) if
 | 
			
		||||
    // we start using dedicated import workers.
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -397,6 +402,11 @@ export class DocManager extends EventEmitter {
 | 
			
		||||
    return makeAccessId(this.gristServer, userId);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public isAnonymous(userId: number): boolean {
 | 
			
		||||
    if (!this._homeDbManager) { throw new Error("HomeDbManager not available"); }
 | 
			
		||||
    return userId === this._homeDbManager.getAnonymousUserId();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Helper function for creating a new shared document given the doc snapshot bundles received
 | 
			
		||||
   * from the sharing hub.
 | 
			
		||||
@ -458,6 +468,7 @@ export class DocManager extends EventEmitter {
 | 
			
		||||
        const docName = await this._createNewDoc(id);
 | 
			
		||||
        const docPath = await this.storageManager.getPath(docName);
 | 
			
		||||
        await docUtils.copyFile(uploadInfo.files[0].absPath, docPath);
 | 
			
		||||
        await this.storageManager.addToStorage(docName);
 | 
			
		||||
        return {title: basename, id: docName};
 | 
			
		||||
      } else {
 | 
			
		||||
        const doc = await this.createNewEmptyDoc(docSession, id);
 | 
			
		||||
 | 
			
		||||
@ -121,6 +121,13 @@ export class DocSnapshotInventory implements IInventory {
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Return true if document inventory does not need to be saved and is not in flux.
 | 
			
		||||
   */
 | 
			
		||||
  public isSaved(key: string) {
 | 
			
		||||
    return !this._needFlush.has(key) && !this._mutex.isLocked(key);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Add a new snapshot of a document to the existing inventory.  A prevSnapshotId may
 | 
			
		||||
   * be supplied as a cross-check.  It will be matched against the most recent
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,6 @@ import {DocEntry, DocEntryTag} from 'app/common/DocListAPI';
 | 
			
		||||
import {DocSnapshots} from 'app/common/DocSnapshot';
 | 
			
		||||
import * as gutil from 'app/common/gutil';
 | 
			
		||||
import * as Comm from 'app/server/lib/Comm';
 | 
			
		||||
import {OptDocSession} from 'app/server/lib/DocSession';
 | 
			
		||||
import * as docUtils from 'app/server/lib/docUtils';
 | 
			
		||||
import {GristServer} from 'app/server/lib/GristServer';
 | 
			
		||||
import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
 | 
			
		||||
@ -85,12 +84,16 @@ export class DocStorageManager implements IDocStorageManager {
 | 
			
		||||
   * Prepares a document for use locally. Returns whether the document is new (needs to be
 | 
			
		||||
   * created). This is a no-op in the local DocStorageManager case.
 | 
			
		||||
   */
 | 
			
		||||
  public async prepareLocalDoc(docName: string, docSession: OptDocSession): Promise<boolean> { return false; }
 | 
			
		||||
  public async prepareLocalDoc(docName: string): Promise<boolean> { return false; }
 | 
			
		||||
 | 
			
		||||
  public async prepareToCreateDoc(docName: string): Promise<void> {
 | 
			
		||||
    // nothing to do
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async prepareFork(srcDocName: string, destDocName: string): Promise<void> {
 | 
			
		||||
    // nothing to do
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Returns a promise for the list of docNames to show in the doc list. For the file-based
 | 
			
		||||
   * storage, this will include all .grist files under the docsRoot.
 | 
			
		||||
 | 
			
		||||
@ -35,6 +35,7 @@ import {IDocStorageManager} from 'app/server/lib/IDocStorageManager';
 | 
			
		||||
import {INotifier} from 'app/server/lib/INotifier';
 | 
			
		||||
import * as log from 'app/server/lib/log';
 | 
			
		||||
import {getLoginMiddleware} from 'app/server/lib/logins';
 | 
			
		||||
import {IPermitStore} from 'app/server/lib/Permit';
 | 
			
		||||
import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places';
 | 
			
		||||
import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint';
 | 
			
		||||
import {PluginManager} from 'app/server/lib/PluginManager';
 | 
			
		||||
@ -221,6 +222,11 @@ export class FlexServer implements GristServer {
 | 
			
		||||
    return this.server ? (this.server.address() as AddressInfo).port : this.port;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public getPermitStore(): IPermitStore {
 | 
			
		||||
    if (!this._docWorkerMap) { throw new Error('no permit store available'); }
 | 
			
		||||
    return this._docWorkerMap;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public addLogging() {
 | 
			
		||||
    if (this._check('logging')) { return; }
 | 
			
		||||
    if (process.env.GRIST_LOG_SKIP_HTTP) { return; }
 | 
			
		||||
 | 
			
		||||
@ -324,13 +324,24 @@ export class GranularAccess {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Check whether user can read everything in document.
 | 
			
		||||
   * Check whether user can read everything in document.  Checks both home-level and doc-level
 | 
			
		||||
   * permissions.
 | 
			
		||||
   */
 | 
			
		||||
  public async canReadEverything(docSession: OptDocSession): Promise<boolean> {
 | 
			
		||||
    const access = getDocSessionAccess(docSession);
 | 
			
		||||
    if (!canView(access)) { return false; }
 | 
			
		||||
    const permInfo = await this._getAccess(docSession);
 | 
			
		||||
    return permInfo.getFullAccess().read === 'allow';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Check whether user can copy everything in document.  Owners can always copy
 | 
			
		||||
   * everything, even if there are rules that specify they cannot.
 | 
			
		||||
   */
 | 
			
		||||
  public async canCopyEverything(docSession: OptDocSession): Promise<boolean> {
 | 
			
		||||
    return this.isOwner(docSession) || this.canReadEverything(docSession);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Check whether user has full access to the document.  Currently that is interpreted
 | 
			
		||||
   * as equivalent owner-level access to the document.
 | 
			
		||||
@ -349,16 +360,6 @@ export class GranularAccess {
 | 
			
		||||
    return access === 'owners';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Check for view access to the document.  For most code paths, a request or message
 | 
			
		||||
   * won't even be considered if there isn't view access, but there's no harm in double
 | 
			
		||||
   * checking.
 | 
			
		||||
   */
 | 
			
		||||
  public hasViewAccess(docSession: OptDocSession): boolean {
 | 
			
		||||
    const access = getDocSessionAccess(docSession);
 | 
			
		||||
    return canView(access);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   *
 | 
			
		||||
   * If the user does not have access to the full document, we need to filter out
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import {SessionUserObj} from 'app/server/lib/BrowserSession';
 | 
			
		||||
import * as Comm from 'app/server/lib/Comm';
 | 
			
		||||
import {Hosts} from 'app/server/lib/extractOrg';
 | 
			
		||||
import {ICreate} from 'app/server/lib/ICreate';
 | 
			
		||||
import {IPermitStore} from 'app/server/lib/Permit';
 | 
			
		||||
import {Sessions} from 'app/server/lib/Sessions';
 | 
			
		||||
import * as express from 'express';
 | 
			
		||||
 | 
			
		||||
@ -14,6 +15,7 @@ export interface GristServer {
 | 
			
		||||
  getHost(): string;
 | 
			
		||||
  getHomeUrl(req: express.Request, relPath?: string): string;
 | 
			
		||||
  getHomeUrlByDocId(docId: string, relPath?: string): Promise<string>;
 | 
			
		||||
  getPermitStore(): IPermitStore;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GristLoginMiddleware {
 | 
			
		||||
 | 
			
		||||
@ -7,9 +7,7 @@ import {buildUrlId, parseUrlId} from 'app/common/gristUrls';
 | 
			
		||||
import {KeyedOps} from 'app/common/KeyedOps';
 | 
			
		||||
import {DocReplacementOptions, NEW_DOCUMENT_CODE} from 'app/common/UserAPI';
 | 
			
		||||
import {HomeDBManager} from 'app/gen-server/lib/HomeDBManager';
 | 
			
		||||
import {getUserId} from 'app/server/lib/Authorizer';
 | 
			
		||||
import {checksumFile} from 'app/server/lib/checksumFile';
 | 
			
		||||
import {OptDocSession} from 'app/server/lib/DocSession';
 | 
			
		||||
import {DocSnapshotInventory, DocSnapshotPruner} from 'app/server/lib/DocSnapshots';
 | 
			
		||||
import {IDocWorkerMap} from 'app/server/lib/DocWorkerMap';
 | 
			
		||||
import {ChecksummedExternalStorage, DELETED_TOKEN, ExternalStorage} from 'app/server/lib/ExternalStorage';
 | 
			
		||||
@ -216,8 +214,10 @@ export class HostedStorageManager implements IDocStorageManager {
 | 
			
		||||
   * Returns whether the document is new (needs to be created).
 | 
			
		||||
   * Calling this method multiple times in parallel for the same document is treated as a sign
 | 
			
		||||
   * of a bug.
 | 
			
		||||
   *
 | 
			
		||||
   * The optional srcDocName parameter is set when preparing a fork.
 | 
			
		||||
   */
 | 
			
		||||
  public async prepareLocalDoc(docName: string, docSession: OptDocSession): Promise<boolean> {
 | 
			
		||||
  public async prepareLocalDoc(docName: string, srcDocName?: string): Promise<boolean> {
 | 
			
		||||
    // We could be reopening a document that is still closing down.
 | 
			
		||||
    // Wait for that to happen.  TODO: we could also try to interrupt the closing-down process.
 | 
			
		||||
    await this.closeDocument(docName);
 | 
			
		||||
@ -228,7 +228,7 @@ export class HostedStorageManager implements IDocStorageManager {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      this._prepareFiles.add(docName);
 | 
			
		||||
      const isNew = !(await this._ensureDocumentIsPresent(docName, docSession));
 | 
			
		||||
      const isNew = !(await this._claimDocument(docName, srcDocName));
 | 
			
		||||
      return isNew;
 | 
			
		||||
    } finally {
 | 
			
		||||
      this._prepareFiles.delete(docName);
 | 
			
		||||
@ -236,17 +236,27 @@ export class HostedStorageManager implements IDocStorageManager {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async prepareToCreateDoc(docName: string): Promise<void> {
 | 
			
		||||
    await this.prepareLocalDoc(docName, 'new');
 | 
			
		||||
    if (this._inventory) {
 | 
			
		||||
      await this._inventory.create(docName);
 | 
			
		||||
      this._onInventoryChange(docName);
 | 
			
		||||
    }
 | 
			
		||||
    this.markAsChanged(docName);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Initialize one document from another, associating the result with the current
 | 
			
		||||
   * worker.
 | 
			
		||||
   */
 | 
			
		||||
  public async prepareFork(srcDocName: string, destDocName: string): Promise<void> {
 | 
			
		||||
    await this.prepareLocalDoc(destDocName, srcDocName);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Gets a copy of the document, eg. for downloading.  Returns full file path.
 | 
			
		||||
  // Copy won't change if edits are made to the document.  It is caller's responsibility
 | 
			
		||||
  // to delete the result.
 | 
			
		||||
  public async getCopy(docName: string): Promise<string> {
 | 
			
		||||
    const present = await this._ensureDocumentIsPresent(docName, {client: null});
 | 
			
		||||
    const present = await this._claimDocument(docName);
 | 
			
		||||
    if (!present) {
 | 
			
		||||
      throw new Error('cannot copy document that does not exist yet');
 | 
			
		||||
    }
 | 
			
		||||
@ -395,9 +405,10 @@ export class HostedStorageManager implements IDocStorageManager {
 | 
			
		||||
    return this._baseStore;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // return true if document is backed up to s3.
 | 
			
		||||
  public isSaved(docName: string): boolean {
 | 
			
		||||
    return !this._uploads.hasPendingOperation(docName);
 | 
			
		||||
  // return true if document and inventory is backed up to external store (if attached).
 | 
			
		||||
  public isAllSaved(docName: string): boolean {
 | 
			
		||||
    return !this._uploads.hasPendingOperation(docName) &&
 | 
			
		||||
      (this._inventory ? this._inventory.isSaved(docName) : true);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // pick up the pace of pushing to s3, from leisurely to urgent.
 | 
			
		||||
@ -423,10 +434,11 @@ export class HostedStorageManager implements IDocStorageManager {
 | 
			
		||||
   * Make sure document is backed up to s3.
 | 
			
		||||
   */
 | 
			
		||||
  public async flushDoc(docName: string): Promise<void> {
 | 
			
		||||
    while (!this.isSaved(docName)) {
 | 
			
		||||
    while (!this.isAllSaved(docName)) {
 | 
			
		||||
      log.info('HostedStorageManager: waiting for document to finish: %s', docName);
 | 
			
		||||
      await this._uploads.expediteOperationAndWait(docName);
 | 
			
		||||
      if (!this.isSaved(docName)) {
 | 
			
		||||
      await this._inventory?.flush(docName);
 | 
			
		||||
      if (!this.isAllSaved(docName)) {
 | 
			
		||||
        // Throttle slightly in case this operation ends up looping excessively.
 | 
			
		||||
        await delay(1000);
 | 
			
		||||
      }
 | 
			
		||||
@ -502,25 +514,28 @@ export class HostedStorageManager implements IDocStorageManager {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Makes sure a document is present locally, fetching it from S3 if necessary.
 | 
			
		||||
   * Returns true on success, false if document not found.  It is safe to call
 | 
			
		||||
   * this method multiple times in parallel.
 | 
			
		||||
   * Makes sure a document is assigned to this worker, adding an
 | 
			
		||||
   * assignment if it has none.  If the document is present in
 | 
			
		||||
   * external storage, fetch it.  Return true if the document was
 | 
			
		||||
   * fetched.
 | 
			
		||||
   *
 | 
			
		||||
   * The document can optionally be copied from an alternative
 | 
			
		||||
   * source (srcDocName).  This is useful for forking.
 | 
			
		||||
   *
 | 
			
		||||
   * If srcDocName is 'new', checks for the document in external storage
 | 
			
		||||
   * are skipped.
 | 
			
		||||
   */
 | 
			
		||||
  private async _ensureDocumentIsPresent(docName: string,
 | 
			
		||||
                                         docSession: OptDocSession): Promise<boolean> {
 | 
			
		||||
  private async _claimDocument(docName: string,
 | 
			
		||||
                               srcDocName?: string): Promise<boolean> {
 | 
			
		||||
    // AsyncCreate.mapGetOrSet ensures we don't start multiple promises to talk to S3/Redis
 | 
			
		||||
    // and that we clean up the failed key in case of failure.
 | 
			
		||||
    return mapGetOrSet(this._localFiles, docName, async () => {
 | 
			
		||||
      if (this._closed) { throw new Error("HostedStorageManager._ensureDocumentIsPresent called after closing"); }
 | 
			
		||||
      checkValidDocId(docName);
 | 
			
		||||
 | 
			
		||||
      const {trunkId, forkId, forkUserId, snapshotId} = parseUrlId(docName);
 | 
			
		||||
      const {trunkId, forkId, snapshotId} = parseUrlId(docName);
 | 
			
		||||
 | 
			
		||||
      // If forkUserId is set to a valid user id, we can only create a fork if we know the
 | 
			
		||||
      // requesting user and their id matches the forkUserId.
 | 
			
		||||
      const userId = (docSession.client && docSession.client.getCachedUserId()) ||
 | 
			
		||||
        (docSession.req && getUserId(docSession.req));
 | 
			
		||||
      const canCreateFork = forkUserId ? (forkUserId === userId) : true;
 | 
			
		||||
      const canCreateFork = Boolean(srcDocName);
 | 
			
		||||
 | 
			
		||||
      const docStatus = await this._docWorkerMap.getDocWorkerOrAssign(docName, this._docWorkerId);
 | 
			
		||||
      if (!docStatus.isActive) { throw new Error(`Doc is not active on a DocWorker: ${docName}`); }
 | 
			
		||||
@ -528,10 +543,12 @@ export class HostedStorageManager implements IDocStorageManager {
 | 
			
		||||
        throw new Error(`Doc belongs to a different DocWorker (${docStatus.docWorker.id}): ${docName}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (srcDocName === 'new') { return false; }
 | 
			
		||||
 | 
			
		||||
      if (this._disableS3) {
 | 
			
		||||
        // skip S3, just use file system
 | 
			
		||||
        let present: boolean = await fse.pathExists(this.getPath(docName));
 | 
			
		||||
        if (forkId && !present) {
 | 
			
		||||
        if ((forkId || snapshotId) && !present) {
 | 
			
		||||
          if (!canCreateFork) { throw new Error(`Cannot create fork`); }
 | 
			
		||||
          if (snapshotId && snapshotId !== 'current') {
 | 
			
		||||
            throw new Error(`cannot find snapshot ${snapshotId} of ${docName}`);
 | 
			
		||||
@ -569,6 +586,7 @@ export class HostedStorageManager implements IDocStorageManager {
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      return this._fetchFromS3(docName, {
 | 
			
		||||
        sourceDocId: srcDocName,
 | 
			
		||||
        trunkId: forkId ? trunkId : undefined, snapshotId, canCreateFork
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,6 @@
 | 
			
		||||
import {DocEntry} from 'app/common/DocListAPI';
 | 
			
		||||
import {DocSnapshots} from 'app/common/DocSnapshot';
 | 
			
		||||
import {DocReplacementOptions} from 'app/common/UserAPI';
 | 
			
		||||
import {OptDocSession} from 'app/server/lib/DocSession';
 | 
			
		||||
 | 
			
		||||
export interface IDocStorageManager {
 | 
			
		||||
  getPath(docName: string): string;
 | 
			
		||||
@ -11,8 +10,9 @@ export interface IDocStorageManager {
 | 
			
		||||
  // This method must not be called for the same docName twice in parallel.
 | 
			
		||||
  // In the current implementation, it is called in the context of an
 | 
			
		||||
  // AsyncCreate[docName].
 | 
			
		||||
  prepareLocalDoc(docName: string, docSession: OptDocSession): Promise<boolean>;
 | 
			
		||||
  prepareLocalDoc(docName: string): Promise<boolean>;
 | 
			
		||||
  prepareToCreateDoc(docName: string): Promise<void>;
 | 
			
		||||
  prepareFork(srcDocName: string, destDocName: string): Promise<void>;
 | 
			
		||||
 | 
			
		||||
  listDocs(): Promise<DocEntry[]>;
 | 
			
		||||
  deleteDoc(docName: string, deletePermanently?: boolean): Promise<void>;
 | 
			
		||||
 | 
			
		||||
@ -28,6 +28,7 @@ export interface Permit {
 | 
			
		||||
  docId?: string;
 | 
			
		||||
  workspaceId?: number;
 | 
			
		||||
  org?: string|number;
 | 
			
		||||
  otherDocId?: string;  // For operations involving two documents.
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* A store of permits */
 | 
			
		||||
 | 
			
		||||
@ -35,14 +35,8 @@ export function makeForkIds(options: { userId: number|null, isAnonymous: boolean
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// For importing, we can assign any worker to the job.  As a hack, we reuse the document
 | 
			
		||||
// assignment mechanism.  To spread the work around a bit if we have several doc workers,
 | 
			
		||||
// we use a fake document id between import0 and import9.
 | 
			
		||||
// This method takes a DocWorkerMap to allow for something smarter in future.
 | 
			
		||||
// This used to do a hack for importing, but now does nothing.
 | 
			
		||||
// Instead, the server will interpret the special docId "import".
 | 
			
		||||
export function getAssignmentId(docWorkerMap: IDocWorkerMap, docId: string): string {
 | 
			
		||||
  let assignmentId = docId;
 | 
			
		||||
  if (assignmentId === 'import') {
 | 
			
		||||
    assignmentId = `import${Math.round(Math.random() * 10)}`;
 | 
			
		||||
  }
 | 
			
		||||
  return assignmentId;
 | 
			
		||||
  return docId;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user