Просмотр исходного кода

Merge pull request #22533 from overleaf/ar-only-use-history-for-blobs-when-enabled

[web] only use history for blobs when enabled

GitOrigin-RevId: 010983e9b29657d4c594e03945dca5700577bf0a
Brian Gough 1 год назад
Родитель
Сommit
2ce82fabab
22 измененных файлов с 302 добавлено и 149 удалено
  1. 2 1
      services/web/app/src/Features/Compile/ClsiManager.js
  2. 24 5
      services/web/app/src/Features/Downloads/ProjectZipStreamManager.mjs
  3. 35 22
      services/web/app/src/Features/FileStore/FileStoreHandler.js
  4. 3 0
      services/web/app/src/Features/History/HistoryController.js
  5. 2 1
      services/web/app/src/Features/History/HistoryURLHelper.js
  6. 3 0
      services/web/app/src/Features/Project/ProjectController.js
  7. 21 8
      services/web/app/src/Features/Project/ProjectDuplicator.js
  8. 8 1
      services/web/app/src/Features/Project/ProjectEditorHandler.js
  9. 6 0
      services/web/app/src/infrastructure/Features.js
  10. 1 0
      services/web/app/views/project/editor/_meta.pug
  11. 2 1
      services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx
  12. 5 1
      services/web/frontend/js/features/utils/fileUrl.js
  13. 1 0
      services/web/frontend/js/utils/meta.ts
  14. 1 1
      services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs
  15. 45 38
      services/web/test/acceptance/src/HistoryTests.mjs
  16. 1 0
      services/web/test/frontend/helpers/reset-meta.ts
  17. 4 0
      services/web/test/unit/src/Compile/ClsiManagerTests.js
  18. 79 46
      services/web/test/unit/src/Downloads/ProjectZipStreamManagerTests.mjs
  19. 53 24
      services/web/test/unit/src/FileStore/FileStoreHandlerTests.js
  20. 4 0
      services/web/test/unit/src/Project/ProjectDuplicatorTests.js
  21. 1 0
      services/web/test/unit/src/References/ReferencesHandlerTests.mjs
  22. 1 0
      services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateSenderTests.js

+ 2 - 1
services/web/app/src/Features/Compile/ClsiManager.js

@@ -15,6 +15,7 @@ const { Cookie } = require('tough-cookie')
 const ClsiCookieManager = require('./ClsiCookieManager')(
   Settings.apis.clsi?.backendGroupName
 )
+const Features = require('../../infrastructure/Features')
 const NewBackendCloudClsiCookieManager = require('./ClsiCookieManager')(
   Settings.apis.clsi_new?.backendGroupName
 )
@@ -744,7 +745,7 @@ function _finaliseRequest(projectId, options, project, docs, files) {
     const filestoreURL = `${Settings.apis.filestore.url}/project/${project._id}/file/${file._id}`
     let url = filestoreURL
     let fallbackURL
-    if (file.hash) {
+    if (file.hash && Features.hasFeature('project-history-blobs')) {
       const { bucket, key } = getBlobLocation(historyId, file.hash)
       url = `${Settings.apis.filestore.url}/bucket/${bucket}/key/${key}`
       fallbackURL = filestoreURL

+ 24 - 5
services/web/app/src/Features/Downloads/ProjectZipStreamManager.mjs

@@ -4,6 +4,8 @@ import logger from '@overleaf/logger'
 import ProjectEntityHandler from '../Project/ProjectEntityHandler.js'
 import ProjectGetter from '../Project/ProjectGetter.js'
 import HistoryManager from '../History/HistoryManager.js'
+import FileStoreHandler from '../FileStore/FileStoreHandler.js'
+import Features from '../../infrastructure/Features.js'
 let ProjectZipStreamManager
 
 export default ProjectZipStreamManager = {
@@ -108,17 +110,35 @@ export default ProjectZipStreamManager = {
     })
   },
 
+  getFileStream: (projectId, file, callback) => {
+    if (Features.hasFeature('project-history-blobs')) {
+      HistoryManager.requestBlobWithFallback(
+        projectId,
+        file.hash,
+        file._id,
+        (error, result) => {
+          if (error) {
+            return callback(error)
+          }
+          const { stream } = result
+          callback(null, stream)
+        }
+      )
+    } else {
+      FileStoreHandler.getFileStream(projectId, file._id, {}, callback)
+    }
+  },
+
   addAllFilesToArchive(projectId, archive, callback) {
     ProjectEntityHandler.getAllFiles(projectId, (error, files) => {
       if (error) {
         return callback(error)
       }
       const jobs = Object.entries(files).map(([path, file]) => cb => {
-        HistoryManager.requestBlobWithFallback(
+        ProjectZipStreamManager.getFileStream(
           projectId,
-          file.hash,
-          file._id,
-          (error, result) => {
+          file,
+          (error, stream) => {
             if (error) {
               logger.warn(
                 { err: error, projectId, fileId: file._id },
@@ -129,7 +149,6 @@ export default ProjectZipStreamManager = {
             if (path[0] === '/') {
               path = path.slice(1)
             }
-            const { stream } = result
             archive.append(stream, { name: path })
             stream.on('end', () => cb())
           }

+ 35 - 22
services/web/app/src/Features/FileStore/FileStoreHandler.js

@@ -11,6 +11,7 @@ const { File } = require('../../models/File')
 const Errors = require('../Errors/Errors')
 const OError = require('@overleaf/o-error')
 const { promisifyAll } = require('@overleaf/promise-utils')
+const Features = require('../../infrastructure/Features')
 
 const ONE_MIN_IN_MS = 60 * 1000
 const FIVE_MINS_IN_MS = ONE_MIN_IN_MS * 5
@@ -38,6 +39,28 @@ const FileStoreHandler = {
     })
   },
 
+  _uploadToHistory(historyId, hash, size, fsPath, callback) {
+    if (Features.hasFeature('project-history-blobs')) {
+      Async.retry(
+        FileStoreHandler.RETRY_ATTEMPTS,
+        cb =>
+          HistoryManager.uploadBlobFromDisk(historyId, hash, size, fsPath, cb),
+        error => callback(error, true)
+      )
+    } else {
+      callback(null, false)
+    }
+  },
+
+  _uploadToFileStore(projectId, fileArgs, fsPath, callback) {
+    Async.retry(
+      FileStoreHandler.RETRY_ATTEMPTS,
+      (cb, results) =>
+        FileStoreHandler._doUploadFileFromDisk(projectId, fileArgs, fsPath, cb),
+      callback
+    )
+  },
+
   uploadFileFromDiskWithHistoryId(
     projectId,
     historyId,
@@ -68,30 +91,20 @@ const FileStoreHandler = {
         if (err) {
           return callback(err)
         }
-        Async.retry(
-          FileStoreHandler.RETRY_ATTEMPTS,
-          cb =>
-            HistoryManager.uploadBlobFromDisk(
-              historyId,
-              hash,
-              stat.size,
-              fsPath,
-              cb
-            ),
-          function (err) {
+        FileStoreHandler._uploadToHistory(
+          historyId,
+          hash,
+          stat.size,
+          fsPath,
+          function (err, createdBlob) {
             if (err) {
               return callback(err)
             }
             fileArgs = { ...fileArgs, hash }
-            Async.retry(
-              FileStoreHandler.RETRY_ATTEMPTS,
-              (cb, results) =>
-                FileStoreHandler._doUploadFileFromDisk(
-                  projectId,
-                  fileArgs,
-                  fsPath,
-                  cb
-                ),
+            FileStoreHandler._uploadToFileStore(
+              projectId,
+              fileArgs,
+              fsPath,
               function (err, result) {
                 if (err) {
                   OError.tag(err, 'Error uploading file, retries failed', {
@@ -100,7 +113,7 @@ const FileStoreHandler = {
                   })
                   return callback(err)
                 }
-                callback(err, result.url, result.fileRef, true)
+                callback(err, result.url, result.fileRef, createdBlob)
               }
             )
           }
@@ -159,7 +172,7 @@ const FileStoreHandler = {
     const historyId = project.overleaf?.history?.id
     const fileId = file._id
     const hash = file.hash
-    if (historyId && hash) {
+    if (historyId && hash && Features.hasFeature('project-history-blobs')) {
       // new behaviour - request from history
       const range = _extractRange(query?.range)
       HistoryManager.requestBlobWithFallback(

+ 3 - 0
services/web/app/src/Features/History/HistoryController.js

@@ -77,6 +77,9 @@ module.exports = HistoryController = {
    *  has a hash.
    * */
   fileToBlobRedirectMiddleware(req, res, next) {
+    if (!Features.hasFeature('project-history-blobs')) {
+      return next()
+    }
     const projectId = req.params.Project_id
     const fileId = req.params.File_id
     ProjectLocator.findElement(

+ 2 - 1
services/web/app/src/Features/History/HistoryURLHelper.js

@@ -7,7 +7,8 @@ function projectHistoryURLWithFilestoreFallback(
   origin
 ) {
   const filestoreURL = `${Settings.apis.filestore.url}/project/${projectId}/file/${fileRef._id}?from=${origin}`
-  if (fileRef.hash) {
+  // TODO: When this file is converted to ES modules we will be able to use Features.hasFeature('project-history-blobs'). Currently we can't stub the feature return value in tests.
+  if (fileRef.hash && Settings.enableProjectHistoryBlobs) {
     return {
       url: `${Settings.apis.project_history.url}/project/${historyId}/blob/${fileRef.hash}`,
       fallbackURL: filestoreURL,

+ 3 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -800,6 +800,9 @@ const _ProjectController = {
           isInvitedMember
         ),
         chatEnabled: Features.hasFeature('chat'),
+        projectHistoryBlobsEnabled: Features.hasFeature(
+          'project-history-blobs'
+        ),
         roMirrorOnClientNoLocalStorage:
           Settings.adminOnlyLogin || project.name.startsWith('Debug: '),
         languages: Settings.languages,

+ 21 - 8
services/web/app/src/Features/Project/ProjectDuplicator.js

@@ -213,7 +213,8 @@ async function _copyFiles(sourceEntries, sourceProject, targetProject) {
         file.hash = sourceFile.hash
       }
       let createdBlob = false
-      if (file.hash != null) {
+      if (file.hash != null && Features.hasFeature('project-history-blobs')) {
+        const usingFilestore = Features.hasFeature('filestore')
         try {
           await HistoryManager.promises.copyBlob(
             sourceHistoryId,
@@ -221,20 +222,32 @@ async function _copyFiles(sourceEntries, sourceProject, targetProject) {
             file.hash
           )
           createdBlob = true
+          if (!usingFilestore) {
+            return { createdBlob, file, path, url: null }
+          }
         } catch (err) {
-          logger.error(
-            {
-              err,
+          if (!usingFilestore) {
+            throw OError.tag(err, 'unexpected error copying blob', {
               sourceProjectId: sourceProject._id,
               targetProjectId: targetProject._id,
               sourceFile,
               sourceHistoryId,
-            },
-            'unexpected error copying blob'
-          )
+            })
+          } else {
+            logger.error(
+              {
+                err,
+                sourceProjectId: sourceProject._id,
+                targetProjectId: targetProject._id,
+                sourceFile,
+                sourceHistoryId,
+              },
+              'unexpected error copying blob'
+            )
+          }
         }
       }
-      if (createdBlob && Features.hasFeature('saas')) {
+      if (createdBlob && Features.hasFeature('project-history-blobs')) {
         return { createdBlob, file, path, url: null }
       }
       const url = await FileStoreHandler.promises.copyFile(

+ 8 - 1
services/web/app/src/Features/Project/ProjectEditorHandler.js

@@ -1,6 +1,7 @@
 let ProjectEditorHandler
 const _ = require('lodash')
 const Path = require('path')
+const Features = require('../../infrastructure/Features')
 
 function mergeDeletedDocs(a, b) {
   const docIdsInA = new Set(a.map(doc => doc._id.toString()))
@@ -123,12 +124,18 @@ module.exports = ProjectEditorHandler = {
   },
 
   buildFileModelView(file) {
+    const additionalFileProperties = {}
+
+    if (Features.hasFeature('project-history-blobs')) {
+      additionalFileProperties.hash = file.hash
+    }
+
     return {
       _id: file._id,
       name: file.name,
       linkedFileData: file.linkedFileData,
       created: file.created,
-      hash: file.hash,
+      ...additionalFileProperties,
     }
   },
 

+ 6 - 0
services/web/app/src/infrastructure/Features.js

@@ -19,6 +19,8 @@ const trackChangesModuleAvailable =
  * @property {boolean | undefined} enableGithubSync
  * @property {boolean | undefined} enableGitBridge
  * @property {boolean | undefined} enableHomepage
+ * @property {boolean | undefined} enableProjectHistoryBlobs
+ * @property {boolean | undefined} disableFilestore
  * @property {boolean | undefined} enableSaml
  * @property {boolean | undefined} ldap
  * @property {boolean | undefined} oauth
@@ -86,6 +88,10 @@ const Features = {
           _.get(Settings, ['apis', 'linkedUrlProxy', 'url']) &&
             Settings.enabledLinkedFileTypes.includes('url')
         )
+      case 'project-history-blobs':
+        return Boolean(Settings.enableProjectHistoryBlobs)
+      case 'filestore':
+        return Boolean(Settings.disableFilestore) === false
       case 'support':
         return supportModuleAvailable
       case 'symbol-palette':

+ 1 - 0
services/web/app/views/project/editor/_meta.pug

@@ -10,6 +10,7 @@ meta(name="ol-isRestrictedTokenMember" data-type="boolean" content=isRestrictedT
 meta(name="ol-maxDocLength" data-type="json" content=maxDocLength)
 meta(name="ol-wikiEnabled" data-type="boolean" content=settings.proxyLearn)
 meta(name="ol-chatEnabled" data-type="boolean" content=chatEnabled)
+meta(name="ol-projectHistoryBlobsEnabled" data-type="boolean" content=projectHistoryBlobsEnabled)
 meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl)
 meta(name="ol-gitBridgeEnabled" data-type="boolean" content=gitBridgeEnabled)
 meta(name="ol-compilesUserContentDomain" content=settings.compilesUserContentDomain)

+ 2 - 1
services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx

@@ -34,6 +34,7 @@ import {
 import { Folder } from '../../../../../types/folder'
 import { useReferencesContext } from '@/features/ide-react/context/references-context'
 import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
+import { fileUrl } from '@/features/utils/fileUrl'
 
 type DroppedFile = File & {
   relativePath?: string
@@ -494,7 +495,7 @@ export const FileTreeActionableProvider: FC = ({ children }) => {
       const selectedEntity = findInTree(fileTreeData, selectedEntityId)
 
       if (selectedEntity?.type === 'fileRef') {
-        return `/project/${projectId}/blob/${selectedEntity.entity.hash}?fallback=${selectedEntityId}`
+        return fileUrl(projectId, selectedEntityId, selectedEntity.entity.hash)
       }
 
       if (selectedEntity?.type === 'doc') {

+ 5 - 1
services/web/frontend/js/features/utils/fileUrl.js

@@ -1,8 +1,12 @@
 // Helper function to compute the url for a file in history-v1 or filestore.
 // This will be obsolete when the migration to history-v1 is complete.
 
+import getMeta from '@/utils/meta'
+
+const projectHistoryBlobsEnabled = getMeta('ol-projectHistoryBlobsEnabled')
+
 export function fileUrl(projectId, id, hash) {
-  if (hash) {
+  if (projectHistoryBlobsEnabled && hash) {
     return `/project/${projectId}/blob/${hash}?fallback=${id}`
   } else {
     return `/project/${projectId}/file/${id}`

+ 1 - 0
services/web/frontend/js/utils/meta.ts

@@ -165,6 +165,7 @@ export interface Meta {
   'ol-prefetchedProjectsBlob': GetProjectsResponseBody | undefined
   'ol-primaryEmail': { email: string; confirmed: boolean }
   'ol-project': any // TODO
+  'ol-projectHistoryBlobsEnabled': boolean
   'ol-projectSyncSuccessMessage': string
   'ol-projectTags': Tag[]
   'ol-project_id': string

+ 1 - 1
services/web/modules/history-v1/test/acceptance/src/ProjectStructureTests.mjs

@@ -257,7 +257,7 @@ describe('ProjectStructureChanges', function () {
       expect(updates[2].type).to.equal('add-file')
       expect(updates[2].userId).to.equal(owner._id)
       expect(updates[2].pathname).to.equal('/frog.jpg')
-      if (Features.hasFeature('saas')) {
+      if (Features.hasFeature('project-history-blobs')) {
         expect(updates[2].url).to.be.null
       } else {
         expect(updates[2].url).to.be.a('string')

+ 45 - 38
services/web/test/acceptance/src/HistoryTests.mjs

@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'
 import sinon from 'sinon'
 import logger from '@overleaf/logger'
 import Metrics from './helpers/metrics.js'
+import Features from '../../../app/src/infrastructure/Features.js'
 const User = UserHelper.promises
 
 let MockV1HistoryApi, MockFilestoreApi
@@ -75,41 +76,45 @@ describe('HistoryTests', function () {
     afterEach(function () {
       spy.restore()
     })
-    it('should work from history-v1', async function () {
-      const { response, body } = await user.doRequest('GET', downloadZIPURL)
-      expect(response.statusCode).to.equal(200)
-      expect(body).to.include('2pixel.png')
-      await expectHistoryV1Hit()
-    })
-    it('should work from filestore', async function () {
-      MockV1HistoryApi.reset()
-      const { response, body } = await user.doRequest('GET', downloadZIPURL)
-      expect(response.statusCode).to.equal(200)
-      expect(body).to.include('2pixel.png')
-      await expectFilestoreHit()
-    })
-    it('should not include when missing in both places', async function () {
-      MockFilestoreApi.reset()
-      MockV1HistoryApi.reset()
-      const { response, body } = await user.doRequest('GET', downloadZIPURL)
-      expect(response.statusCode).to.equal(200)
-      expect(
-        spy.args.find(([, msg]) => msg === 'error adding files to zip stream')
-      ).to.exist
-      expect(body).to.not.include('2pixel.png')
-      await expectNoIncrement()
-    })
+    if (Features.hasFeature('project-history-blobs')) {
+      it('should work from history-v1', async function () {
+        const { response, body } = await user.doRequest('GET', downloadZIPURL)
+        expect(response.statusCode).to.equal(200)
+        expect(body).to.include('2pixel.png')
+        await expectHistoryV1Hit()
+      })
+      it('should work from filestore', async function () {
+        MockV1HistoryApi.reset()
+        const { response, body } = await user.doRequest('GET', downloadZIPURL)
+        expect(response.statusCode).to.equal(200)
+        expect(body).to.include('2pixel.png')
+        await expectFilestoreHit()
+      })
+      it('should not include when missing in both places', async function () {
+        MockFilestoreApi.reset()
+        MockV1HistoryApi.reset()
+        const { response, body } = await user.doRequest('GET', downloadZIPURL)
+        expect(response.statusCode).to.equal(200)
+        expect(
+          spy.args.find(([, msg]) => msg === 'error adding files to zip stream')
+        ).to.exist
+        expect(body).to.not.include('2pixel.png')
+        await expectNoIncrement()
+      })
+    }
   })
 
   describe('/project/:projectId/blob/:hash', function () {
     describe('HEAD', function () {
-      it('should fetch the file size from history-v1', async function () {
-        const { response } = await user.doRequest('HEAD', fileURL)
-        expect(response.statusCode).to.equal(200)
-        expect(response.headers['x-served-by']).to.include('history-v1')
-        expect(response.headers['content-length']).to.equal('3694')
-        await expectHistoryV1Hit()
-      })
+      if (Features.hasFeature('project-history-blobs')) {
+        it('should fetch the file size from history-v1', async function () {
+          const { response } = await user.doRequest('HEAD', fileURL)
+          expect(response.statusCode).to.equal(200)
+          expect(response.headers['x-served-by']).to.include('history-v1')
+          expect(response.headers['content-length']).to.equal('3694')
+          await expectHistoryV1Hit()
+        })
+      }
       it('should return 404 without fallback', async function () {
         MockV1HistoryApi.reset()
         const { response } = await user.doRequest('HEAD', fileURL)
@@ -133,13 +138,15 @@ describe('HistoryTests', function () {
       })
     })
     describe('GET', function () {
-      it('should fetch the file from history-v1', async function () {
-        const { response, body } = await user.doRequest('GET', fileURL)
-        expect(response.statusCode).to.equal(200)
-        expect(response.headers['x-served-by']).to.include('history-v1')
-        expect(body).to.equal(fileContent)
-        await expectHistoryV1Hit()
-      })
+      if (Features.hasFeature('project-history-blobs')) {
+        it('should fetch the file from history-v1', async function () {
+          const { response, body } = await user.doRequest('GET', fileURL)
+          expect(response.statusCode).to.equal(200)
+          expect(response.headers['x-served-by']).to.include('history-v1')
+          expect(body).to.equal(fileContent)
+          await expectHistoryV1Hit()
+        })
+      }
       it('should return 404 without fallback', async function () {
         MockV1HistoryApi.reset()
         const { response } = await user.doRequest('GET', fileURL)

+ 1 - 0
services/web/test/frontend/helpers/reset-meta.ts

@@ -1,5 +1,6 @@
 export function resetMeta() {
   window.metaAttributesCache = new Map()
+  window.metaAttributesCache.set('ol-projectHistoryBlobsEnabled', true)
   window.metaAttributesCache.set('ol-i18n', { currentLangCode: 'en' })
   window.metaAttributesCache.set('ol-ExposedSettings', {
     appName: 'Overleaf',

+ 4 - 0
services/web/test/unit/src/Compile/ClsiManagerTests.js

@@ -144,6 +144,9 @@ describe('ClsiManager', function () {
       enablePdfCaching: true,
       clsiCookie: { key: 'clsiserver' },
     }
+    this.Features = {
+      hasFeature: sinon.stub().withArgs('project-history-blobs').returns(true),
+    }
     this.HistoryManager = {
       getBlobLocation: sinon.stub().callsFake((historyId, hash) => {
         if (hash === GLOBAL_BLOB_HASH) {
@@ -162,6 +165,7 @@ describe('ClsiManager', function () {
         '../../models/Project': {
           Project: this.Project,
         },
+        '../../infrastructure/Features': this.Features,
         '../Project/ProjectEntityHandler': this.ProjectEntityHandler,
         '../Project/ProjectGetter': this.ProjectGetter,
         '../DocumentUpdater/DocumentUpdaterHandler':

+ 79 - 46
services/web/test/unit/src/Downloads/ProjectZipStreamManagerTests.mjs

@@ -27,6 +27,7 @@ describe('ProjectZipStreamManager', function () {
       info: sinon.stub(),
       debug: sinon.stub(),
     }
+
     return (this.ProjectZipStreamManager = await esmock.strict(modulePath, {
       archiver: (this.archiver = sinon.stub().returns(this.archive)),
       '@overleaf/logger': this.logger,
@@ -36,6 +37,14 @@ describe('ProjectZipStreamManager', function () {
         (this.HistoryManager = {}),
       '../../../../app/src/Features/Project/ProjectGetter':
         (this.ProjectGetter = {}),
+      '../../../../app/src/Features/FileStore/FileStoreHandler':
+        (this.FileStoreHandler = {}),
+      '../../../../app/src/infrastructure/Features': (this.Features = {
+        hasFeature: sinon
+          .stub()
+          .withArgs('project-history-blobs')
+          .returns(true),
+      }),
     }))
   })
 
@@ -380,65 +389,89 @@ describe('ProjectZipStreamManager', function () {
       this.ProjectEntityHandler.getAllFiles = sinon
         .stub()
         .callsArgWith(1, null, this.files)
-      this.HistoryManager.requestBlobWithFallback = (
-        projectId,
-        hash,
-        fileId,
-        callback
-      ) => {
-        return callback(null, { stream: this.streams[fileId] })
-      }
-      sinon.spy(this.HistoryManager, 'requestBlobWithFallback')
-      this.ProjectZipStreamManager.addAllFilesToArchive(
-        this.project_id,
-        this.archive,
-        this.callback
-      )
-      return (() => {
-        const result = []
+    })
+    describe('with project-history-blobs feature enabled', function () {
+      beforeEach(function () {
+        this.HistoryManager.requestBlobWithFallback = (
+          projectId,
+          hash,
+          fileId,
+          callback
+        ) => {
+          return callback(null, { stream: this.streams[fileId] })
+        }
+        sinon.spy(this.HistoryManager, 'requestBlobWithFallback')
+        this.ProjectZipStreamManager.addAllFilesToArchive(
+          this.project_id,
+          this.archive,
+          this.callback
+        )
         for (const path in this.streams) {
           const stream = this.streams[path]
-          result.push(stream.emit('end'))
+          stream.emit('end')
         }
-        return result
-      })()
-    })
+      })
 
-    it('should get the files for the project', function () {
-      return this.ProjectEntityHandler.getAllFiles
-        .calledWith(this.project_id)
-        .should.equal(true)
-    })
+      it('should get the files for the project', function () {
+        return this.ProjectEntityHandler.getAllFiles
+          .calledWith(this.project_id)
+          .should.equal(true)
+      })
 
-    it('should get a stream for each file', function () {
-      return (() => {
-        const result = []
+      it('should get a stream for each file', function () {
         for (const path in this.files) {
           const file = this.files[path]
-          result.push(
-            this.HistoryManager.requestBlobWithFallback
-              .calledWith(this.project_id, file.hash, file._id)
-              .should.equal(true)
-          )
+
+          this.HistoryManager.requestBlobWithFallback
+            .calledWith(this.project_id, file.hash, file._id)
+            .should.equal(true)
         }
-        return result
-      })()
-    })
+      })
 
-    it('should add each file to the archive', function () {
-      return (() => {
-        const result = []
+      it('should add each file to the archive', function () {
         for (let path in this.files) {
           const file = this.files[path]
           path = path.slice(1) // remove "/"
-          result.push(
-            this.archive.append
-              .calledWith(this.streams[file._id], { name: path })
-              .should.equal(true)
-          )
+          this.archive.append
+            .calledWith(this.streams[file._id], { name: path })
+            .should.equal(true)
         }
-        return result
-      })()
+      })
+    })
+
+    describe('with project-history-blobs feature disabled', function () {
+      beforeEach(function () {
+        this.FileStoreHandler.getFileStream = (
+          projectId,
+          fileId,
+          query,
+          callback
+        ) => callback(null, this.streams[fileId])
+
+        sinon.spy(this.FileStoreHandler, 'getFileStream')
+        this.Features.hasFeature
+          .withArgs('project-history-blobs')
+          .returns(false)
+        this.ProjectZipStreamManager.addAllFilesToArchive(
+          this.project_id,
+          this.archive,
+          this.callback
+        )
+        for (const path in this.streams) {
+          const stream = this.streams[path]
+          stream.emit('end')
+        }
+      })
+
+      it('should get a stream for each file', function () {
+        for (const path in this.files) {
+          const file = this.files[path]
+
+          this.FileStoreHandler.getFileStream
+            .calledWith(this.project_id, file._id)
+            .should.equal(true)
+        }
+      })
     })
   })
 })

+ 53 - 24
services/web/test/unit/src/FileStore/FileStoreHandlerTests.js

@@ -8,6 +8,7 @@ const MODULE_PATH = '../../../../app/src/Features/FileStore/FileStoreHandler.js'
 
 describe('FileStoreHandler', function () {
   beforeEach(function () {
+    this.fileSize = 999
     this.fs = {
       createReadStream: sinon.stub(),
       lstat: sinon.stub().callsArgWith(1, null, {
@@ -40,7 +41,6 @@ describe('FileStoreHandler', function () {
     this.fileId = 'file_id_here'
     this.projectId = '1312312312'
     this.historyId = 123
-    this.fileSize = 999
     this.hashValue = '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'
     this.fsPath = 'uploads/myfile.eps'
     this.getFileUrl = (projectId, fileId) =>
@@ -69,6 +69,10 @@ describe('FileStoreHandler', function () {
       }),
     }
 
+    this.Features = {
+      hasFeature: sinon.stub(),
+    }
+
     this.handler = SandboxedModule.require(MODULE_PATH, {
       requires: {
         '@overleaf/settings': this.settings,
@@ -76,6 +80,7 @@ describe('FileStoreHandler', function () {
         '../History/HistoryManager': this.HistoryManager,
         '../Project/ProjectDetailsHandler': this.ProjectDetailsHandler,
         './FileHashManager': this.FileHashManager,
+        '../../infrastructure/Features': this.Features,
         // FIXME: need to stub File object here
         '../../models/File': {
           File: this.FileModel,
@@ -134,31 +139,55 @@ describe('FileStoreHandler', function () {
       )
     })
 
-    it('should upload the file to the history store as a blob', function (done) {
-      this.fs.createReadStream.returns({
-        pipe() {},
-        on(type, cb) {
-          if (type === 'open') {
-            cb()
+    describe('when project-history-blobs feature is enabled', function () {
+      it('should upload the file to the history store as a blob', function (done) {
+        this.fs.createReadStream.returns({
+          pipe() {},
+          on(type, cb) {
+            if (type === 'open') {
+              cb()
+            }
+          },
+        })
+        this.Features.hasFeature.withArgs('project-history-blobs').returns(true)
+        this.handler.uploadFileFromDisk(
+          this.projectId,
+          this.fileArgs,
+          this.fsPath,
+          () => {
+            this.HistoryManager.uploadBlobFromDisk
+              .calledWith(
+                this.historyId,
+                this.hashValue,
+                this.fileSize,
+                this.fsPath
+              )
+              .should.equal(true)
+            done()
           }
-        },
+        )
+      })
+    })
+    describe('when project-history-blobs feature is disabled', function () {
+      it('should not upload the file to the history store as a blob', function (done) {
+        this.fs.createReadStream.returns({
+          pipe() {},
+          on(type, cb) {
+            if (type === 'open') {
+              cb()
+            }
+          },
+        })
+        this.handler.uploadFileFromDisk(
+          this.projectId,
+          this.fileArgs,
+          this.fsPath,
+          () => {
+            this.HistoryManager.uploadBlobFromDisk.called.should.equal(false)
+            done()
+          }
+        )
       })
-      this.handler.uploadFileFromDisk(
-        this.projectId,
-        this.fileArgs,
-        this.fsPath,
-        () => {
-          this.HistoryManager.uploadBlobFromDisk
-            .calledWith(
-              this.historyId,
-              this.hashValue,
-              this.fileSize,
-              this.fsPath
-            )
-            .should.equal(true)
-          done()
-        }
-      )
     })
 
     it('should create read stream', function (done) {

+ 4 - 0
services/web/test/unit/src/Project/ProjectDuplicatorTests.js

@@ -221,6 +221,9 @@ describe('ProjectDuplicator', function () {
         flushProjectToTpds: sinon.stub().resolves(),
       },
     }
+    this.Features = {
+      hasFeature: sinon.stub().withArgs('project-history-blobs').returns(true),
+    }
 
     this.ProjectDuplicator = SandboxedModule.require(MODULE_PATH, {
       requires: {
@@ -241,6 +244,7 @@ describe('ProjectDuplicator', function () {
         '../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
         '../Tags/TagsHandler': this.TagsHandler,
         '../History/HistoryManager': this.HistoryManager,
+        '../../infrastructure/Features': this.Features,
       },
     })
   })

+ 1 - 0
services/web/test/unit/src/References/ReferencesHandlerTests.mjs

@@ -54,6 +54,7 @@ describe('ReferencesHandler', function () {
           filestore: { url: 'http://some.url/filestore' },
           project_history: { url: 'http://project-history.local' },
         },
+        enableProjectHistoryBlobs: true,
       }),
       request: (this.request = {
         get: sinon.stub(),

+ 1 - 0
services/web/test/unit/src/ThirdPartyDataStore/TpdsUpdateSenderTests.js

@@ -57,6 +57,7 @@ describe('TpdsUpdateSender', function () {
           url: projectHistoryUrl,
         },
       },
+      enableProjectHistoryBlobs: true,
     }
     const getUsers = sinon.stub()
     getUsers