Explorar o código

Merge pull request #21681 from overleaf/jpa-back-fill-file-hash

[history-v1] add script for back filling hash into project file-tree

GitOrigin-RevId: 70ab8c72f3eea1062708e4471b4ad1c60317ad26
Jakob Ackermann hai 1 ano
pai
achega
97b39ef98f

+ 1 - 0
libraries/mongo-utils/batchedUpdate.js

@@ -277,6 +277,7 @@ function batchedUpdateWithResultHandling(
 }
 
 module.exports = {
+  READ_PREFERENCE_SECONDARY,
   batchedUpdate,
   batchedUpdateWithResultHandling,
 }

+ 4 - 4
libraries/object-persistor/src/AbstractPersistor.js

@@ -35,12 +35,12 @@ module.exports = class AbstractPersistor {
   /**
    * @param location
    * @param name
-   * @param {Object} opts
-   * @param {Number} opts.start
-   * @param {Number} opts.end
+   * @param {Object} [opts]
+   * @param {Number} [opts.start]
+   * @param {Number} [opts.end]
    * @return {Promise<NodeJS.ReadableStream>}
    */
-  async getObjectStream(location, name, opts) {
+  async getObjectStream(location, name, opts = {}) {
     throw new NotImplementedError('method not implemented in persistor', {
       method: 'getObjectStream',
       location,

+ 2 - 0
libraries/object-persistor/src/PerProjectEncryptedS3Persistor.js

@@ -201,6 +201,7 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
         // Do not overwrite any objects if already created
         ifNoneMatch: '*',
         ssecOptions: await this.#getCurrentKeyEncryptionKey(projectFolder),
+        contentLength: 32,
       }
     )
     return new SSECOptions(dataEncryptionKey)
@@ -404,6 +405,7 @@ class CachedPerProjectEncryptedS3Persistor {
    * @param {Object} opts
    * @param {string} [opts.contentType]
    * @param {string} [opts.contentEncoding]
+   * @param {number} [opts.contentLength]
    * @param {'*'} [opts.ifNoneMatch]
    * @param {SSECOptions} [opts.ssecOptions]
    * @param {string} [opts.sourceMd5]

+ 14 - 4
libraries/object-persistor/src/S3Persistor.js

@@ -85,6 +85,7 @@ class S3Persistor extends AbstractPersistor {
    * @param {Object} opts
    * @param {string} [opts.contentType]
    * @param {string} [opts.contentEncoding]
+   * @param {number} [opts.contentLength]
    * @param {'*'} [opts.ifNoneMatch]
    * @param {SSECOptions} [opts.ssecOptions]
    * @param {string} [opts.sourceMd5]
@@ -118,6 +119,9 @@ class S3Persistor extends AbstractPersistor {
       if (opts.contentEncoding) {
         uploadOptions.ContentEncoding = opts.contentEncoding
       }
+      if (opts.contentLength) {
+        uploadOptions.ContentLength = opts.contentLength
+      }
       if (opts.ifNoneMatch === '*') {
         uploadOptions.IfNoneMatch = '*'
       }
@@ -134,9 +138,15 @@ class S3Persistor extends AbstractPersistor {
         clientOptions.computeChecksums = true
       }
 
-      await this._getClientForBucket(bucketName, clientOptions)
-        .upload(uploadOptions, { partSize: this.settings.partSize })
-        .promise()
+      if (this.settings.disableMultiPartUpload) {
+        await this._getClientForBucket(bucketName, clientOptions)
+          .putObject(uploadOptions)
+          .promise()
+      } else {
+        await this._getClientForBucket(bucketName, clientOptions)
+          .upload(uploadOptions, { partSize: this.settings.partSize })
+          .promise()
+      }
     } catch (err) {
       throw PersistorHelper.wrapError(
         err,
@@ -150,7 +160,7 @@ class S3Persistor extends AbstractPersistor {
   /**
    * @param {string} bucketName
    * @param {string} key
-   * @param {Object} opts
+   * @param {Object} [opts]
    * @param {number} [opts.start]
    * @param {number} [opts.end]
    * @param {boolean} [opts.autoGunzip]

+ 25 - 0
package-lock.json

@@ -5759,6 +5759,17 @@
         "node": ">=10"
       }
     },
+    "node_modules/@google-cloud/secret-manager": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/@google-cloud/secret-manager/-/secret-manager-5.6.0.tgz",
+      "integrity": "sha512-0daW/OXQEVc6VQKPyJTQNyD+563I/TYQ7GCQJx4dq3lB666R9FUPvqHx9b/o/qQtZ5pfuoCbGZl3krpxgTSW8Q==",
+      "dependencies": {
+        "google-gax": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/@google-cloud/storage": {
       "version": "6.10.1",
       "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-6.10.1.tgz",
@@ -39688,10 +39699,13 @@
       "version": "1.0.0",
       "license": "Proprietary",
       "dependencies": {
+        "@google-cloud/secret-manager": "^5.6.0",
         "@overleaf/logger": "*",
         "@overleaf/metrics": "*",
+        "@overleaf/mongo-utils": "*",
         "@overleaf/o-error": "*",
         "@overleaf/object-persistor": "*",
+        "@overleaf/promise-utils": "*",
         "@overleaf/redis-wrapper": "*",
         "@overleaf/settings": "*",
         "@overleaf/stream-utils": "^0.1.0",
@@ -47889,6 +47903,14 @@
       "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-2.0.4.tgz",
       "integrity": "sha512-j8yRSSqswWi1QqUGKVEKOG03Q7qOoZP6/h2zN2YO+F5h2+DHU0bSrHCK9Y7lo2DI9fBd8qGAw795sf+3Jva4yA=="
     },
+    "@google-cloud/secret-manager": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/@google-cloud/secret-manager/-/secret-manager-5.6.0.tgz",
+      "integrity": "sha512-0daW/OXQEVc6VQKPyJTQNyD+563I/TYQ7GCQJx4dq3lB666R9FUPvqHx9b/o/qQtZ5pfuoCbGZl3krpxgTSW8Q==",
+      "requires": {
+        "google-gax": "^4.0.3"
+      }
+    },
     "@google-cloud/storage": {
       "version": "6.10.1",
       "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-6.10.1.tgz",
@@ -69319,10 +69341,13 @@
     "overleaf-editor": {
       "version": "file:services/history-v1",
       "requires": {
+        "@google-cloud/secret-manager": "^5.6.0",
         "@overleaf/logger": "*",
         "@overleaf/metrics": "*",
+        "@overleaf/mongo-utils": "*",
         "@overleaf/o-error": "*",
         "@overleaf/object-persistor": "*",
+        "@overleaf/promise-utils": "*",
         "@overleaf/redis-wrapper": "*",
         "@overleaf/settings": "*",
         "@overleaf/stream-utils": "^0.1.0",

+ 1 - 1
services/history-v1/buildscript.txt

@@ -1,5 +1,5 @@
 history-v1
---dependencies=postgres,gcs,mongo
+--dependencies=postgres,gcs,mongo,s3
 --docker-repos=us-east1-docker.pkg.dev/overleaf-ops/ol-docker
 --env-add=
 --env-pass-through=

+ 21 - 0
services/history-v1/config/custom-environment-variables.json

@@ -8,6 +8,8 @@
     "s3": {
       "key": "AWS_ACCESS_KEY_ID",
       "secret": "AWS_SECRET_ACCESS_KEY",
+      "endpoint": "AWS_S3_ENDPOINT",
+      "pathStyle": "AWS_S3_PATH_STYLE",
       "maxRetries": "S3_MAX_RETRIES",
       "httpOptions": {
         "timeout": "S3_TIMEOUT"
@@ -30,6 +32,19 @@
       "buckets": "PERSISTOR_BUCKET_MAPPING"
     }
   },
+  "backupPersistor": {
+    "keyEncryptionKeys": "BACKUP_KEY_ENCRYPTION_KEYS",
+    "s3SSEC": {
+      "key": "AWS_ACCESS_KEY_ID",
+      "secret": "AWS_SECRET_ACCESS_KEY",
+      "endpoint": "AWS_S3_ENDPOINT",
+      "pathStyle": "AWS_S3_PATH_STYLE",
+      "maxRetries": "BACKUP_S3_MAX_RETRIES",
+      "httpOptions": {
+        "timeout": "BACKUP_S3_TIMEOUT"
+      }
+    }
+  },
   "blobStore": {
     "globalBucket": "OVERLEAF_EDITOR_BLOBS_BUCKET",
     "projectBucket": "OVERLEAF_EDITOR_PROJECT_BLOBS_BUCKET"
@@ -42,6 +57,12 @@
     "bucket": "OVERLEAF_EDITOR_ZIPS_BUCKET",
     "zipTimeoutMs": "ZIP_STORE_ZIP_TIMEOUT_MS"
   },
+  "backupStore": {
+    "chunksBucket":"BACKUP_OVERLEAF_EDITOR_CHUNKS_BUCKET",
+    "deksBucket":"BACKUP_OVERLEAF_EDITOR_DEKS_BUCKET",
+    "globalBlobsBucket":"BACKUP_OVERLEAF_EDITOR_GLOBAL_BLOBS_BUCKET",
+    "projectBlobsBucket":"BACKUP_OVERLEAF_EDITOR_PROJECT_BLOBS_BUCKET"
+  },
   "mongo": {
     "uri": "MONGO_CONNECTION_STRING"
   },

+ 10 - 0
services/history-v1/config/default.json

@@ -13,6 +13,16 @@
       "deleteConcurrency": "50"
     }
   },
+  "backupPersistor": {
+    "backend": "s3SSEC",
+    "s3SSEC": {
+      "maxRetries": "1",
+      "pathStyle": false,
+      "httpOptions": {
+        "timeout": "8000"
+      }
+    }
+  },
   "chunkStore": {
     "historyStoreConcurrency": "4"
   },

+ 12 - 0
services/history-v1/config/development.json

@@ -23,6 +23,18 @@
   "zipStore": {
     "bucket": "overleaf-development-zips"
   },
+  "backupStore": {
+    "chunksBucket":"overleaf-development-history-chunks",
+    "deksBucket":"overleaf-development-history-deks",
+    "globalBlobsBucket":"overleaf-development-history-global-blobs",
+    "projectBlobsBucket":"overleaf-development-history-project-blobs"
+  },
+  "backupPersistor": {
+    "keyEncryptionKeys": "[{\"key\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\",\"salt\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}]",
+    "s3SSEC": {
+      "ca": "[\"/certs/public.crt\"]"
+    }
+  },
   "useDeleteObjects": "false",
   "mongo": {
     "uri": "mongodb://mongo:27017/sharelatex"

+ 5 - 1
services/history-v1/config/production.json

@@ -1 +1,5 @@
-{ }
+{
+  "backupPersistor": {
+    "tieringStorageClass": "INTELLIGENT_TIERING"
+  }
+}

+ 13 - 0
services/history-v1/config/test.json

@@ -20,6 +20,19 @@
   "zipStore": {
     "bucket": "overleaf-test-zips"
   },
+  "backupStore": {
+    "chunksBucket":"overleaf-test-history-chunks",
+    "deksBucket":"overleaf-test-history-deks",
+    "globalBlobsBucket":"overleaf-test-history-global-blobs",
+    "projectBlobsBucket":"overleaf-test-history-project-blobs"
+  },
+  "backupPersistor": {
+    "keyEncryptionKeys": "[{\"key\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\",\"salt\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}]",
+    "s3SSEC": {
+      "ca": "[\"/certs/public.crt\"]"
+    },
+    "tieringStorageClass": "REDUCED_REDUNDANCY"
+  },
   "maxDeleteKeys": "3",
   "useDeleteObjects": "false",
   "mongo": {

+ 145 - 0
services/history-v1/docker-compose.ci.yml

@@ -21,17 +21,31 @@ services:
       ELASTIC_SEARCH_DSN: es:9200
       MONGO_HOST: mongo
       POSTGRES_HOST: postgres
+      AWS_S3_ENDPOINT: https://minio:9000
+      AWS_S3_PATH_STYLE: 'true'
+      AWS_ACCESS_KEY_ID: OVERLEAF_HISTORY_S3_ACCESS_KEY_ID
+      AWS_SECRET_ACCESS_KEY: OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY
+      MINIO_ROOT_USER: MINIO_ROOT_USER
+      MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD
       GCS_API_ENDPOINT: http://gcs:9090
       GCS_PROJECT_ID: fake
       STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1
       MOCHA_GREP: ${MOCHA_GREP}
       NODE_ENV: test
       NODE_OPTIONS: "--unhandled-rejections=strict"
+    volumes:
+      - ./test/acceptance/certs:/certs
     depends_on:
       mongo:
         condition: service_healthy
       postgres:
         condition: service_healthy
+      certs:
+        condition: service_completed_successfully
+      minio:
+        condition: service_started
+      minio_setup:
+        condition: service_completed_successfully
       gcs:
         condition: service_healthy
     user: node
@@ -63,6 +77,137 @@ services:
       interval: 1s
       retries: 20
 
+  certs:
+    image: node:18.20.4
+    volumes:
+      - ./test/acceptance/certs:/certs
+    working_dir: /certs
+    entrypoint: sh
+    command:
+      - '-cex'
+      - |
+        if [ ! -f ./certgen ]; then
+          wget -O ./certgen "https://github.com/minio/certgen/releases/download/v1.3.0/certgen-linux-$(dpkg --print-architecture)"
+          chmod +x ./certgen
+        fi
+        if [ ! -f private.key ] || [ ! -f public.crt ]; then
+          ./certgen -host minio
+        fi
+
+  minio:
+    image: minio/minio:RELEASE.2024-10-13T13-34-11Z
+    command: server /data
+    volumes:
+      - ./test/acceptance/certs:/root/.minio/certs
+    environment:
+      MINIO_ROOT_USER: MINIO_ROOT_USER
+      MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD
+    depends_on:
+      certs:
+        condition: service_completed_successfully
+
+  minio_setup:
+    depends_on:
+      certs:
+        condition: service_completed_successfully
+      minio:
+        condition: service_started
+    image: minio/mc:RELEASE.2024-10-08T09-37-26Z
+    volumes:
+      - ./test/acceptance/certs:/root/.mc/certs/CAs
+    entrypoint: sh
+    command:
+      - '-cex'
+      - |
+        sleep 1
+        mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \
+        || sleep 3 && \
+        mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \
+        || sleep 3 && \
+        mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \
+        || sleep 3 && \
+        mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD
+        mc mb --ignore-existing s3/overleaf-test-history-chunks
+        mc mb --ignore-existing s3/overleaf-test-history-deks
+        mc mb --ignore-existing s3/overleaf-test-history-global-blobs
+        mc mb --ignore-existing s3/overleaf-test-history-project-blobs
+        mc admin user add s3 \
+          OVERLEAF_HISTORY_S3_ACCESS_KEY_ID \
+          OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY
+        echo '
+          {
+            "Version": "2012-10-17",
+            "Statement": [
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:ListBucket"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-chunks"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:PutObject",
+                  "s3:GetObject",
+                  "s3:DeleteObject"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-chunks/*"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:ListBucket"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-deks"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:PutObject",
+                  "s3:GetObject",
+                  "s3:DeleteObject"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-deks/*"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:ListBucket"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-global-blobs"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:PutObject",
+                  "s3:GetObject",
+                  "s3:DeleteObject"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-global-blobs/*"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:ListBucket"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-project-blobs"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:PutObject",
+                  "s3:GetObject",
+                  "s3:DeleteObject"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-project-blobs/*"
+              }
+            ]
+          }' > policy-history.json
+
+        mc admin policy create s3 overleaf-history policy-history.json
+        mc admin policy attach s3 overleaf-history \
+          --user=OVERLEAF_HISTORY_S3_ACCESS_KEY_ID
   gcs:
     image: fsouza/fake-gcs-server:1.45.2
     command: ["--port=9090", "--scheme=http"]

+ 144 - 0
services/history-v1/docker-compose.yml

@@ -25,11 +25,18 @@ services:
       - .:/overleaf/services/history-v1
       - ../../node_modules:/overleaf/node_modules
       - ../../libraries:/overleaf/libraries
+      - ./test/acceptance/certs:/certs
     working_dir: /overleaf/services/history-v1
     environment:
       ELASTIC_SEARCH_DSN: es:9200
       MONGO_HOST: mongo
       POSTGRES_HOST: postgres
+      AWS_S3_ENDPOINT: https://minio:9000
+      AWS_S3_PATH_STYLE: 'true'
+      AWS_ACCESS_KEY_ID: OVERLEAF_HISTORY_S3_ACCESS_KEY_ID
+      AWS_SECRET_ACCESS_KEY: OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY
+      MINIO_ROOT_USER: MINIO_ROOT_USER
+      MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD
       GCS_API_ENDPOINT: http://gcs:9090
       GCS_PROJECT_ID: fake
       STORAGE_EMULATOR_HOST: http://gcs:9090/storage/v1
@@ -43,6 +50,12 @@ services:
         condition: service_healthy
       postgres:
         condition: service_healthy
+      certs:
+        condition: service_completed_successfully
+      minio:
+        condition: service_started
+      minio_setup:
+        condition: service_completed_successfully
       gcs:
         condition: service_healthy
     command: npm run --silent test:acceptance
@@ -66,6 +79,137 @@ services:
       interval: 1s
       retries: 20
 
+  certs:
+    image: node:18.20.4
+    volumes:
+      - ./test/acceptance/certs:/certs
+    working_dir: /certs
+    entrypoint: sh
+    command:
+      - '-cex'
+      - |
+        if [ ! -f ./certgen ]; then
+          wget -O ./certgen "https://github.com/minio/certgen/releases/download/v1.3.0/certgen-linux-$(dpkg --print-architecture)"
+          chmod +x ./certgen
+        fi
+        if [ ! -f private.key ] || [ ! -f public.crt ]; then
+          ./certgen -host minio
+        fi
+
+  minio:
+    image: minio/minio:RELEASE.2024-10-13T13-34-11Z
+    command: server /data
+    volumes:
+      - ./test/acceptance/certs:/root/.minio/certs
+    environment:
+      MINIO_ROOT_USER: MINIO_ROOT_USER
+      MINIO_ROOT_PASSWORD: MINIO_ROOT_PASSWORD
+    depends_on:
+      certs:
+        condition: service_completed_successfully
+
+  minio_setup:
+    depends_on:
+      certs:
+        condition: service_completed_successfully
+      minio:
+        condition: service_started
+    image: minio/mc:RELEASE.2024-10-08T09-37-26Z
+    volumes:
+      - ./test/acceptance/certs:/root/.mc/certs/CAs
+    entrypoint: sh
+    command:
+      - '-cex'
+      - |
+        sleep 1
+        mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \
+        || sleep 3 && \
+        mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \
+        || sleep 3 && \
+        mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD \
+        || sleep 3 && \
+        mc alias set s3 https://minio:9000 MINIO_ROOT_USER MINIO_ROOT_PASSWORD
+        mc mb --ignore-existing s3/overleaf-test-history-chunks
+        mc mb --ignore-existing s3/overleaf-test-history-deks
+        mc mb --ignore-existing s3/overleaf-test-history-global-blobs
+        mc mb --ignore-existing s3/overleaf-test-history-project-blobs
+        mc admin user add s3 \
+          OVERLEAF_HISTORY_S3_ACCESS_KEY_ID \
+          OVERLEAF_HISTORY_S3_SECRET_ACCESS_KEY
+        echo '
+          {
+            "Version": "2012-10-17",
+            "Statement": [
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:ListBucket"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-chunks"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:PutObject",
+                  "s3:GetObject",
+                  "s3:DeleteObject"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-chunks/*"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:ListBucket"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-deks"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:PutObject",
+                  "s3:GetObject",
+                  "s3:DeleteObject"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-deks/*"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:ListBucket"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-global-blobs"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:PutObject",
+                  "s3:GetObject",
+                  "s3:DeleteObject"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-global-blobs/*"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:ListBucket"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-project-blobs"
+              },
+              {
+                "Effect": "Allow",
+                "Action": [
+                  "s3:PutObject",
+                  "s3:GetObject",
+                  "s3:DeleteObject"
+                ],
+                "Resource": "arn:aws:s3:::overleaf-test-history-project-blobs/*"
+              }
+            ]
+          }' > policy-history.json
+
+        mc admin policy create s3 overleaf-history policy-history.json
+        mc admin policy attach s3 overleaf-history \
+          --user=OVERLEAF_HISTORY_S3_ACCESS_KEY_ID
   gcs:
     image: fsouza/fake-gcs-server:1.45.2
     command: ["--port=9090", "--scheme=http"]

+ 3 - 0
services/history-v1/package.json

@@ -6,10 +6,13 @@
   "license": "Proprietary",
   "private": true,
   "dependencies": {
+    "@google-cloud/secret-manager": "^5.6.0",
     "@overleaf/logger": "*",
     "@overleaf/metrics": "*",
+    "@overleaf/mongo-utils": "*",
     "@overleaf/o-error": "*",
     "@overleaf/object-persistor": "*",
+    "@overleaf/promise-utils": "*",
     "@overleaf/redis-wrapper": "*",
     "@overleaf/settings": "*",
     "@overleaf/stream-utils": "^0.1.0",

+ 107 - 0
services/history-v1/storage/lib/backupPersistor.mjs

@@ -0,0 +1,107 @@
+// @ts-check
+import fs from 'node:fs'
+import Path from 'node:path'
+import _ from 'lodash'
+import config from 'config'
+import { SecretManagerServiceClient } from '@google-cloud/secret-manager'
+import {
+  PerProjectEncryptedS3Persistor,
+  RootKeyEncryptionKey,
+} from '@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor.js'
+
+const persistorConfig = _.cloneDeep(config.get('backupPersistor'))
+const { chunksBucket, deksBucket, globalBlobsBucket, projectBlobsBucket } =
+  config.get('backupStore')
+
+export { chunksBucket, globalBlobsBucket, projectBlobsBucket }
+
+function convertKey(key, convertFn) {
+  if (_.has(persistorConfig, key)) {
+    _.update(persistorConfig, key, convertFn)
+  }
+}
+
+convertKey('s3SSEC.httpOptions.timeout', s => parseInt(s, 10))
+convertKey('s3SSEC.maxRetries', s => parseInt(s, 10))
+convertKey('s3SSEC.pathStyle', s => s === 'true')
+// array of CA, either inlined or on disk
+convertKey('s3SSEC.ca', s =>
+  JSON.parse(s).map(ca => (ca.startsWith('/') ? fs.readFileSync(ca) : ca))
+)
+
+/** @type {() => Promise<string>} */
+let getRawRootKeyEncryptionKeys
+
+if ((process.env.NODE_ENV || 'production') === 'production') {
+  ;[persistorConfig.s3SSEC.key, persistorConfig.s3SSEC.secret] = (
+    await loadFromSecretsManager(
+      process.env.BACKUP_AWS_CREDENTIALS || '',
+      'BACKUP_AWS_CREDENTIALS'
+    )
+  ).split(':')
+  getRawRootKeyEncryptionKeys = () =>
+    loadFromSecretsManager(
+      persistorConfig.keyEncryptionKeys,
+      'BACKUP_KEY_ENCRYPTION_KEYS'
+    )
+} else {
+  getRawRootKeyEncryptionKeys = () => persistorConfig.keyEncryptionKeys
+}
+
+export const DELETION_ONLY = persistorConfig.keyEncryptionKeys === 'none'
+if (DELETION_ONLY) {
+  // For Backup-deleter; should not encrypt or read data; deleting does not need key.
+  getRawRootKeyEncryptionKeys = () => new Promise(_resolve => {})
+}
+
+/**
+ * @param {string} bucketName
+ * @param {string} path
+ * @return {string}
+ */
+function pathToProjectFolder(bucketName, path) {
+  switch (bucketName) {
+    case deksBucket:
+    case chunksBucket:
+    case projectBlobsBucket:
+      return Path.join(...path.split('/').slice(0, 3)) + '/'
+    default:
+      throw new Error(`${bucketName} does not store per-project files`)
+  }
+}
+
+/**
+ * @param {string} name
+ * @param {string} label
+ * @return {Promise<string>}
+ */
+async function loadFromSecretsManager(name, label) {
+  const client = new SecretManagerServiceClient()
+  const [version] = await client.accessSecretVersion({ name })
+  if (!version.payload?.data) throw new Error(`empty secret: ${label}`)
+  return version.payload.data.toString()
+}
+
+async function getRootKeyEncryptionKeys() {
+  return JSON.parse(await getRawRootKeyEncryptionKeys()).map(
+    ({ key, salt }) => {
+      return new RootKeyEncryptionKey(
+        Buffer.from(key, 'base64'),
+        Buffer.from(salt, 'base64')
+      )
+    }
+  )
+}
+
+export const backupPersistor = new PerProjectEncryptedS3Persistor({
+  ...persistorConfig.s3SSEC,
+  disableMultiPartUpload: true,
+  dataEncryptionKeyBucketName: deksBucket,
+  pathToProjectFolder,
+  getRootKeyEncryptionKeys,
+  storageClass: {
+    [deksBucket]: 'STANDARD',
+    [chunksBucket]: persistorConfig.tieringStorageClass,
+    [projectBlobsBucket]: persistorConfig.tieringStorageClass,
+  },
+})

+ 1 - 1
services/history-v1/storage/lib/blob_store/index.js

@@ -337,4 +337,4 @@ class BlobStore {
   }
 }
 
-module.exports = { BlobStore, loadGlobalBlobs }
+module.exports = { BlobStore, loadGlobalBlobs, makeProjectKey, GLOBAL_BLOBS }

+ 13 - 1
services/history-v1/storage/lib/mongodb.js

@@ -10,7 +10,19 @@ const chunks = db.collection('projectHistoryChunks')
 const blobs = db.collection('projectHistoryBlobs')
 const globalBlobs = db.collection('projectHistoryGlobalBlobs')
 const shardedBlobs = db.collection('projectHistoryShardedBlobs')
+// Temporary collection for tracking progress of backed up old blobs (without a hash).
+// The initial sync process will be able to skip over these.
+// Schema: _id: projectId, blobs: [Binary]
+const backedUpBlobs = db.collection('projectHistoryBackedUpBlobs')
 
 Metrics.mongodb.monitor(client)
 
-module.exports = { client, db, chunks, blobs, globalBlobs, shardedBlobs }
+module.exports = {
+  client,
+  db,
+  chunks,
+  blobs,
+  globalBlobs,
+  shardedBlobs,
+  backedUpBlobs,
+}

+ 824 - 0
services/history-v1/storage/scripts/back_fill_file_hash.mjs

@@ -0,0 +1,824 @@
+// @ts-check
+import Crypto from 'node:crypto'
+import Events from 'node:events'
+import fs from 'node:fs'
+import Path from 'node:path'
+import Stream from 'node:stream'
+import zLib from 'node:zlib'
+import { setTimeout } from 'node:timers/promises'
+import { Binary, ObjectId } from 'mongodb'
+import logger from '@overleaf/logger'
+import {
+  batchedUpdate,
+  READ_PREFERENCE_SECONDARY,
+} from '@overleaf/mongo-utils/batchedUpdate.js'
+import OError from '@overleaf/o-error'
+import {
+  AlreadyWrittenError,
+  NoKEKMatchedError,
+  NotFoundError,
+} from '@overleaf/object-persistor/src/Errors.js'
+import { promiseMapWithLimit } from '@overleaf/promise-utils'
+import { backupPersistor, projectBlobsBucket } from '../lib/backupPersistor.mjs'
+import {
+  BlobStore,
+  GLOBAL_BLOBS,
+  loadGlobalBlobs,
+  makeProjectKey,
+} from '../lib/blob_store/index.js'
+import { backedUpBlobs, db } from '../lib/mongodb.js'
+import filestorePersistor from '../lib/persistor.js'
+
+// Silence warning.
+Events.setMaxListeners(20)
+
+// Enable caching for ObjectId.toString()
+ObjectId.cacheHexString = true
+
+/**
+ * @typedef {import("overleaf-editor-core").Blob} Blob
+ * @typedef {import("mongodb").Collection} Collection
+ * @typedef {import("@overleaf/object-persistor/src/PerProjectEncryptedS3Persistor").CachedPerProjectEncryptedS3Persistor} CachedPerProjectEncryptedS3Persistor
+ */
+
+/**
+ * @typedef {Object} FileRef
+ * @property {ObjectId} _id
+ * @property {string} hash
+ */
+
+/**
+ * @typedef {Object} Folder
+ * @property {Array<Folder>} folders
+ * @property {Array<FileRef>} fileRefs
+ */
+
+/**
+ * @typedef {Object} DeletedFileRef
+ * @property {ObjectId} _id
+ * @property {ObjectId} projectId
+ * @property {string} hash
+ */
+
+/**
+ * @typedef {Object} Project
+ * @property {ObjectId} _id
+ * @property {Array<Folder>} rootFolder
+ * @property {Array<string>} deletedFileIds
+ * @property {{history: {id: string}}} overleaf
+ */
+
+/**
+ * @typedef {Object} QueueEntry
+ * @property {ProjectContext} ctx
+ * @property {string} fileId
+ * @property {string} path
+ * @property {string} [hash]
+ */
+
+// Time of closing the ticket for adding hashes: https://github.com/overleaf/internal/issues/464#issuecomment-492668129
+const ALL_PROJECTS_HAVE_FILE_HASHES_AFTER = new Date('2019-05-15T14:02:00Z')
+const PUBLIC_LAUNCH_DATE = new Date('2012-01-01T00:00:00Z')
+const BATCH_RANGE_START =
+  process.env.BATCH_RANGE_START ||
+  ObjectId.createFromTime(PUBLIC_LAUNCH_DATE.getTime() / 1000).toString()
+const BATCH_RANGE_END =
+  process.env.BATCH_RANGE_END ||
+  ObjectId.createFromTime(
+    ALL_PROJECTS_HAVE_FILE_HASHES_AFTER.getTime() / 1000
+  ).toString()
+// We need to control the start and end as ids of deleted projects are created at time of deletion.
+delete process.env.BATCH_RANGE_START
+delete process.env.BATCH_RANGE_END
+
+// Concurrency for downloading from GCS and updating hashes in mongo
+const CONCURRENCY = parseInt(process.env.CONCURRENCY || '100', 10)
+// Retries for processing a given file
+const RETRIES = parseInt(process.env.RETRIES || '10', 10)
+const RETRY_DELAY_MS = parseInt(process.env.RETRY_DELAY_MS || '100', 10)
+
+const USER_FILES_BUCKET_NAME = process.env.USER_FILES_BUCKET_NAME || ''
+if (!USER_FILES_BUCKET_NAME) {
+  throw new Error('env var USER_FILES_BUCKET_NAME is missing')
+}
+const RETRY_FILESTORE_404 = process.env.RETRY_FILESTORE_404 === 'true'
+const BUFFER_DIR = fs.mkdtempSync(
+  process.env.BUFFER_DIR_PREFIX || '/tmp/back_fill_file_hash-'
+)
+
+const projectsCollection = db.collection('projects')
+const deletedProjectsCollection = db.collection('deletedProjects')
+const deletedFilesCollection = db.collection('deletedFiles')
+
+const STATS = {
+  projects: 0,
+  filesWithoutHash: 0,
+  filesDuplicated: 0,
+  filesRetries: 0,
+  filesFailed: 0,
+  fileTreeUpdated: 0,
+  globalBlobsCount: 0,
+  globalBlobsEgress: 0,
+  projectDeleted: 0,
+  projectHardDeleted: 0,
+  fileHardDeleted: 0,
+  mongoUpdates: 0,
+  deduplicatedWriteToAWSLocalCount: 0,
+  deduplicatedWriteToAWSLocalEgress: 0,
+  deduplicatedWriteToAWSRemoteCount: 0,
+  deduplicatedWriteToAWSRemoteEgress: 0,
+  writeToAWSCount: 0,
+  writeToAWSEgress: 0,
+}
+
+function printStats() {
+  console.log(
+    JSON.stringify({
+      time: new Date(),
+      ...STATS,
+    })
+  )
+}
+
+setInterval(printStats, 60_000)
+
+/**
+ * @param {QueueEntry} entry
+ * @return {Promise<string>}
+ */
+async function processFile(entry) {
+  for (let attempt = 0; attempt < RETRIES; attempt++) {
+    try {
+      return await processFileOnce(entry)
+    } catch (err) {
+      if (err instanceof NotFoundError) {
+        const { bucketName } = OError.getFullInfo(err)
+        if (bucketName === USER_FILES_BUCKET_NAME && !RETRY_FILESTORE_404) {
+          throw err // disable retries for not found in filestore bucket case
+        }
+      }
+      if (err instanceof NoKEKMatchedError) {
+        throw err // disable retries when upload to S3 will fail again
+      }
+      STATS.filesRetries++
+      const {
+        ctx: { projectId },
+        fileId,
+        path,
+      } = entry
+      logger.warn(
+        { err, projectId, fileId, path, attempt },
+        'failed to process file, trying again'
+      )
+      await setTimeout(RETRY_DELAY_MS)
+    }
+  }
+  return await processFileOnce(entry)
+}
+
+/**
+ * @param {QueueEntry} entry
+ * @return {Promise<string>}
+ */
+async function processFileOnce(entry) {
+  const { fileId } = entry
+  const { projectId, historyId } = entry.ctx
+  const filePath = Path.join(
+    BUFFER_DIR,
+    projectId.toString() + fileId.toString()
+  )
+  const dst = fs.createWriteStream(filePath)
+  const src = await filestorePersistor.getObjectStream(
+    USER_FILES_BUCKET_NAME,
+    `${projectId}/${fileId}`
+  )
+  await Stream.promises.pipeline(src, dst)
+
+  const blobStore = new BlobStore(historyId)
+  const blob = await blobStore.putFile(filePath)
+  const hash = blob.getHash()
+
+  if (GLOBAL_BLOBS.has(hash)) {
+    STATS.globalBlobsCount++
+    STATS.globalBlobsEgress += estimateBlobSize(blob)
+    return hash
+  }
+
+  if (entry.ctx.hasBackedUpBlob(hash)) {
+    STATS.deduplicatedWriteToAWSLocalCount++
+    STATS.deduplicatedWriteToAWSLocalEgress += estimateBlobSize(blob)
+    return hash
+  }
+  entry.ctx.recordPendingBlob(hash)
+
+  let backupSource
+  let contentEncoding
+  const md5 = Crypto.createHash('md5')
+  let size
+  if (blob.getStringLength()) {
+    const filePathCompressed = filePath + '.gz'
+    backupSource = filePathCompressed
+    contentEncoding = 'gzip'
+    size = 0
+    await Stream.promises.pipeline(
+      fs.createReadStream(filePath),
+      zLib.createGzip(),
+      async function* (source) {
+        for await (const chunk of source) {
+          size += chunk.byteLength
+          md5.update(chunk)
+          yield chunk
+        }
+      },
+      fs.createWriteStream(filePathCompressed)
+    )
+  } else {
+    backupSource = filePath
+    size = blob.getByteLength()
+    await Stream.promises.pipeline(fs.createReadStream(filePath), md5)
+  }
+  const backendKeyPath = makeProjectKey(historyId, blob.getHash())
+  const persistor = await entry.ctx.getCachedPersistor(backendKeyPath)
+  try {
+    STATS.writeToAWSCount++
+    await persistor.sendStream(
+      projectBlobsBucket,
+      backendKeyPath,
+      fs.createReadStream(backupSource),
+      {
+        contentEncoding,
+        contentType: 'application/octet-stream',
+        contentLength: size,
+        sourceMd5: md5.digest('hex'),
+        ifNoneMatch: '*', // de-duplicate write (we pay for the request, but avoid egress)
+      }
+    )
+    STATS.writeToAWSEgress += size
+  } catch (err) {
+    if (err instanceof AlreadyWrittenError) {
+      STATS.deduplicatedWriteToAWSRemoteCount++
+      STATS.deduplicatedWriteToAWSRemoteEgress += size
+    } else {
+      STATS.writeToAWSEgress += size
+      entry.ctx.recordFailedBlob(hash)
+      throw err
+    }
+  }
+  entry.ctx.recordBackedUpBlob(hash)
+  return hash
+}
+
+/**
+ * @param {Array<QueueEntry>} files
+ * @return {Promise<void>}
+ */
+async function processFiles(files) {
+  if (files.length === 0) return // all processed
+  await fs.promises.mkdir(BUFFER_DIR, { recursive: true })
+  try {
+    await promiseMapWithLimit(
+      CONCURRENCY,
+      files,
+      /**
+       * @param {QueueEntry} entry
+       * @return {Promise<void>}
+       */
+      async function (entry) {
+        try {
+          await entry.ctx.processFile(entry)
+        } catch (err) {
+          STATS.filesFailed++
+          const {
+            ctx: { projectId },
+            fileId,
+            path,
+          } = entry
+          logger.error(
+            { err, projectId, fileId, path },
+            'failed to process file'
+          )
+        }
+      }
+    )
+  } finally {
+    await fs.promises.rm(BUFFER_DIR, { recursive: true, force: true })
+  }
+}
+
+/**
+ * @param {Array<Project>} batch
+ * @param {string} prefix
+ * @return {Promise<void>}
+ */
+async function handleLiveTreeBatch(batch, prefix = 'rootFolder.0') {
+  if (process.argv.includes('deletedFiles')) {
+    await collectDeletedFiles(batch)
+  }
+  const files = Array.from(findFileInBatch(batch, prefix))
+  STATS.projects += batch.length
+  STATS.filesWithoutHash += files.length
+  batch.length = 0 // GC
+  // The files are currently ordered by project-id.
+  // Order them by file-id to
+  // - avoid head-of-line blocking from many project-files waiting on the generation of the projects DEK (round trip to AWS)
+  // - bonus: increase chance of de-duplicating write to AWS
+  files.sort((a, b) => (a.fileId > b.fileId ? 1 : -1))
+  await processFiles(files)
+  await promiseMapWithLimit(
+    CONCURRENCY,
+    files,
+    /**
+     * @param {QueueEntry} entry
+     * @return {Promise<void>}
+     */
+    async function (entry) {
+      await entry.ctx.flushMongoQueues()
+    }
+  )
+}
+
+/**
+ * @param {Array<{project: Project}>} batch
+ * @return {Promise<void>}
+ */
+async function handleDeletedFileTreeBatch(batch) {
+  await handleLiveTreeBatch(
+    batch.map(d => d.project),
+    'project.rootFolder.0'
+  )
+}
+
+/**
+ * @param {QueueEntry} entry
+ * @return {Promise<boolean>}
+ */
+async function tryUpdateFileRefInMongo(entry) {
+  if (entry.path === '') {
+    return await tryUpdateDeletedFileRefInMongo(entry)
+  } else if (entry.path.startsWith('project.')) {
+    return await tryUpdateFileRefInMongoInDeletedProject(entry)
+  }
+
+  STATS.mongoUpdates++
+  const result = await projectsCollection.updateOne(
+    {
+      _id: entry.ctx.projectId,
+      [`${entry.path}._id`]: new ObjectId(entry.fileId),
+    },
+    {
+      $set: { [`${entry.path}.hash`]: entry.hash },
+    }
+  )
+  return result.matchedCount === 1
+}
+
+/**
+ * @param {QueueEntry} entry
+ * @return {Promise<boolean>}
+ */
+async function tryUpdateDeletedFileRefInMongo(entry) {
+  STATS.mongoUpdates++
+  const result = await deletedFilesCollection.updateOne(
+    {
+      _id: new ObjectId(entry.fileId),
+      projectId: entry.ctx.projectId,
+    },
+    { $set: { hash: entry.hash } }
+  )
+  return result.matchedCount === 1
+}
+
+/**
+ * @param {QueueEntry} entry
+ * @return {Promise<boolean>}
+ */
+async function tryUpdateFileRefInMongoInDeletedProject(entry) {
+  STATS.mongoUpdates++
+  const result = await deletedProjectsCollection.updateOne(
+    {
+      'deleterData.deletedProjectId': entry.ctx.projectId,
+      [`${entry.path}._id`]: new ObjectId(entry.fileId),
+    },
+    {
+      $set: { [`${entry.path}.hash`]: entry.hash },
+    }
+  )
+  return result.matchedCount === 1
+}
+
+const RETRY_UPDATE_HASH = 100
+
+/**
+ * @param {QueueEntry} entry
+ * @return {Promise<void>}
+ */
+async function updateFileRefInMongo(entry) {
+  if (await tryUpdateFileRefInMongo(entry)) return
+
+  const { fileId } = entry
+  const { projectId } = entry.ctx
+  for (let i = 0; i < RETRY_UPDATE_HASH; i++) {
+    let prefix = 'rootFolder.0'
+    let p = await projectsCollection.findOne(
+      { _id: projectId },
+      { projection: { rootFolder: 1 } }
+    )
+    if (!p) {
+      STATS.projectDeleted++
+      prefix = 'project.rootFolder.0'
+      const deletedProject = await deletedProjectsCollection.findOne(
+        {
+          'deleterData.deletedProjectId': projectId,
+          project: { $exists: true },
+        },
+        { projection: { 'project.rootFolder': 1 } }
+      )
+      p = deletedProject?.project
+      if (!p) {
+        STATS.projectHardDeleted++
+        console.warn(
+          'bug: project hard-deleted while processing',
+          projectId,
+          fileId
+        )
+        return
+      }
+    }
+    let found = false
+    for (const e of findFiles(entry.ctx, p.rootFolder[0], prefix)) {
+      found = e.fileId === fileId
+      if (!found) continue
+      if (await tryUpdateFileRefInMongo(e)) return
+      break
+    }
+    if (!found) {
+      if (await tryUpdateDeletedFileRefInMongo(entry)) return
+      STATS.fileHardDeleted++
+      console.warn('bug: file hard-deleted while processing', projectId, fileId)
+      return
+    }
+
+    STATS.fileTreeUpdated++
+  }
+  throw new OError(
+    'file-tree updated repeatedly while trying to add hash',
+    entry
+  )
+}
+
+/**
+ * @param {ProjectContext} ctx
+ * @param {Folder} folder
+ * @param {string} path
+ * @return Generator<QueueEntry>
+ */
+function* findFiles(ctx, folder, path) {
+  let i = 0
+  for (const child of folder.folders) {
+    yield* findFiles(ctx, child, `${path}.folders.${i}`)
+    i++
+  }
+  i = 0
+  for (const fileRef of folder.fileRefs) {
+    if (!fileRef.hash) {
+      yield {
+        ctx,
+        fileId: fileRef._id.toString(),
+        path: `${path}.fileRefs.${i}`,
+      }
+    }
+    i++
+  }
+}
+
+/**
+ * @param {Array<Project>} projects
+ * @param {string} prefix
+ */
+function* findFileInBatch(projects, prefix) {
+  for (const project of projects) {
+    const ctx = new ProjectContext(project)
+    yield* findFiles(ctx, project.rootFolder[0], prefix)
+    for (const fileId of project.deletedFileIds || []) {
+      yield { ctx, fileId, path: '' }
+    }
+  }
+}
+
+/**
+ * @param {Array<Project>} projects
+ * @return {Promise<void>}
+ */
+async function collectDeletedFiles(projects) {
+  const cursor = deletedFilesCollection.find(
+    {
+      projectId: { $in: projects.map(p => p._id) },
+      hash: { $exists: false },
+    },
+    {
+      projection: { _id: 1, projectId: 1 },
+      readPreference: READ_PREFERENCE_SECONDARY,
+      sort: { projectId: 1 },
+    }
+  )
+  const processed = projects.slice()
+  for await (const deletedFileRef of cursor) {
+    const idx = processed.findIndex(
+      p => p._id.toString() === deletedFileRef.projectId.toString()
+    )
+    if (idx === -1) {
+      throw new Error(
+        `bug: order of deletedFiles mongo records does not match batch of projects (${deletedFileRef.projectId} out of order)`
+      )
+    }
+    processed.splice(0, idx)
+    const project = processed[0]
+    project.deletedFileIds = project.deletedFileIds || []
+    project.deletedFileIds.push(deletedFileRef._id.toString())
+  }
+}
+
+const BATCH_HASH_WRITES = 1_000
+const BATCH_FILE_UPDATES = 100
+
+class ProjectContext {
+  /** @type {Promise<CachedPerProjectEncryptedS3Persistor> | null} */
+  #cachedPersistorPromise = null
+
+  /**
+   * @param {Project} project
+   */
+  constructor(project) {
+    this.projectId = project._id
+    this.historyId = project.overleaf.history.id.toString()
+  }
+
+  /**
+   * @param {string} key
+   * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
+   */
+  getCachedPersistor(key) {
+    if (!this.#cachedPersistorPromise) {
+      // Fetch DEK once, but only if needed -- upon the first use
+      this.#cachedPersistorPromise = this.#getCachedPersistorWithRetries(key)
+    }
+    return this.#cachedPersistorPromise
+  }
+
+  /**
+   * @param {string} key
+   * @return {Promise<CachedPerProjectEncryptedS3Persistor>}
+   */
+  async #getCachedPersistorWithRetries(key) {
+    for (let attempt = 0; attempt < RETRIES; attempt++) {
+      try {
+        return await backupPersistor.forProject(projectBlobsBucket, key)
+      } catch (err) {
+        if (err instanceof NoKEKMatchedError) {
+          throw err
+        } else {
+          logger.warn(
+            { err, projectId: this.projectId, attempt },
+            'failed to get DEK, trying again'
+          )
+          await setTimeout(RETRY_DELAY_MS)
+        }
+      }
+    }
+    return await backupPersistor.forProject(projectBlobsBucket, key)
+  }
+
+  async flushMongoQueuesIfNeeded() {
+    if (this.#completedBlobs.size > BATCH_HASH_WRITES) {
+      await this.#storeBackedUpBlobs()
+    }
+    if (this.#pendingFileWrites.length > BATCH_FILE_UPDATES) {
+      await this.#storeFileHashes()
+    }
+  }
+
+  async flushMongoQueues() {
+    await this.#storeBackedUpBlobs()
+    await this.#storeFileHashes()
+  }
+
+  /** @type {Set<string>} */
+  #pendingBlobs = new Set()
+  /** @type {Set<string>} */
+  #completedBlobs = new Set()
+
+  async #storeBackedUpBlobs() {
+    if (this.#completedBlobs.size === 0) return
+    const blobs = Array.from(this.#completedBlobs).map(
+      hash => new Binary(Buffer.from(hash, 'hex'))
+    )
+    this.#completedBlobs.clear()
+    STATS.mongoUpdates++
+    await backedUpBlobs.updateOne(
+      { _id: this.projectId },
+      { $addToSet: { blobs: { $each: blobs } } },
+      { upsert: true }
+    )
+  }
+
+  /**
+   * @param {string} hash
+   */
+  recordPendingBlob(hash) {
+    this.#pendingBlobs.add(hash)
+  }
+
+  /**
+   * @param {string} hash
+   */
+  recordFailedBlob(hash) {
+    this.#pendingBlobs.delete(hash)
+  }
+
+  /**
+   * @param {string} hash
+   */
+  recordBackedUpBlob(hash) {
+    this.#completedBlobs.add(hash)
+    this.#pendingBlobs.delete(hash)
+  }
+
+  /**
+   * @param {string} hash
+   * @return {boolean}
+   */
+  hasBackedUpBlob(hash) {
+    return this.#pendingBlobs.has(hash) || this.#completedBlobs.has(hash)
+  }
+
+  /** @type {Array<QueueEntry>} */
+  #pendingFileWrites = []
+
+  /**
+   * @param {QueueEntry} entry
+   */
+  queueFileForWritingHash(entry) {
+    this.#pendingFileWrites.push(entry)
+  }
+
+  /**
+   * @param {Collection} collection
+   * @param {Array<QueueEntry>} entries
+   * @param {Object} query
+   * @return {Promise<Array<QueueEntry>>}
+   */
+  async #tryBatchHashWrites(collection, entries, query) {
+    if (entries.length === 0) return []
+    const update = {}
+    for (const entry of entries) {
+      query[`${entry.path}._id`] = new ObjectId(entry.fileId)
+      update[`${entry.path}.hash`] = entry.hash
+    }
+    STATS.mongoUpdates++
+    const result = await collection.updateOne(query, { $set: update })
+    if (result.matchedCount === 1) {
+      return [] // all updated
+    }
+    return entries
+  }
+
+  async #storeFileHashes() {
+    if (this.#pendingFileWrites.length === 0) return
+    const individualUpdates = []
+    const projectEntries = []
+    const deletedProjectEntries = []
+    for (const entry of this.#pendingFileWrites) {
+      if (entry.path === '') {
+        individualUpdates.push(entry)
+      } else if (entry.path.startsWith('project.')) {
+        deletedProjectEntries.push(entry)
+      } else {
+        projectEntries.push(entry)
+      }
+    }
+    this.#pendingFileWrites.length = 0
+
+    // Try to process them together, otherwise fallback to individual updates and retries.
+    individualUpdates.push(
+      ...(await this.#tryBatchHashWrites(projectsCollection, projectEntries, {
+        _id: this.projectId,
+      }))
+    )
+    individualUpdates.push(
+      ...(await this.#tryBatchHashWrites(
+        deletedProjectsCollection,
+        deletedProjectEntries,
+        { 'deleterData.deletedProjectId': this.projectId }
+      ))
+    )
+    for (const entry of individualUpdates) {
+      await updateFileRefInMongo(entry)
+    }
+  }
+
+  /** @type {Map<string, Promise<string>>} */
+  #pendingFiles = new Map()
+
+  /**
+   * @param {QueueEntry} entry
+   */
+  async processFile(entry) {
+    if (this.#pendingFiles.has(entry.fileId)) {
+      STATS.filesDuplicated++
+    } else {
+      this.#pendingFiles.set(entry.fileId, processFile(entry))
+    }
+    entry.hash = await this.#pendingFiles.get(entry.fileId)
+    this.queueFileForWritingHash(entry)
+    await this.flushMongoQueuesIfNeeded()
+  }
+}
+
+/**
+ * @param {Blob} blob
+ * @return {number}
+ */
+function estimateBlobSize(blob) {
+  let size = blob.getByteLength()
+  if (blob.getStringLength()) {
+    // approximation for gzip (25 bytes gzip overhead and 20% compression ratio)
+    size = 25 + Math.ceil(size * 0.2)
+  }
+  return size
+}
+
+async function updateLiveFileTrees() {
+  await batchedUpdate(
+    projectsCollection,
+    { 'overleaf.history.id': { $exists: true } },
+    handleLiveTreeBatch,
+    { rootFolder: 1, _id: 1, 'overleaf.history.id': 1 },
+    {},
+    {
+      BATCH_RANGE_START,
+      BATCH_RANGE_END,
+    }
+  )
+  console.warn('Done updating live projects')
+}
+
+async function updateDeletedFileTrees() {
+  await batchedUpdate(
+    deletedProjectsCollection,
+    {
+      'deleterData.deletedProjectId': {
+        $gt: new ObjectId(BATCH_RANGE_START),
+        $lte: new ObjectId(BATCH_RANGE_END),
+      },
+      'project.overleaf.history.id': { $exists: true },
+    },
+    handleDeletedFileTreeBatch,
+    {
+      'project.rootFolder': 1,
+      'project._id': 1,
+      'project.overleaf.history.id': 1,
+    }
+  )
+  console.warn('Done updating deleted projects')
+}
+
+async function main() {
+  await loadGlobalBlobs()
+  if (process.argv.includes('live')) {
+    await updateLiveFileTrees()
+  }
+  if (process.argv.includes('deleted')) {
+    await updateDeletedFileTrees()
+  }
+  console.warn('Done.')
+}
+
+try {
+  try {
+    await main()
+  } finally {
+    printStats()
+  }
+
+  let code = 0
+  if (STATS.filesFailed > 0) {
+    console.warn('Some files could not be processed, see logs and try again')
+    code++
+  }
+  if (STATS.fileHardDeleted > 0) {
+    console.warn(
+      'Some hashes could not be updated as the files were hard-deleted, this should not happen'
+    )
+    code++
+  }
+  if (STATS.projectHardDeleted > 0) {
+    console.warn(
+      'Some hashes could not be updated as the project was hard-deleted, this should not happen'
+    )
+    code++
+  }
+  process.exit(code)
+} catch (err) {
+  console.error(err)
+  process.exit(1)
+}

+ 2 - 0
services/history-v1/test/acceptance/certs/.gitignore

@@ -0,0 +1,2 @@
+*
+!.gitignore

+ 1009 - 0
services/history-v1/test/acceptance/js/storage/back_fill_file_hash.test.mjs

@@ -0,0 +1,1009 @@
+import Crypto from 'node:crypto'
+import Stream from 'node:stream'
+import { setTimeout } from 'node:timers/promises'
+import { promisify } from 'node:util'
+import { ObjectId, Binary } from 'mongodb'
+import {
+  db,
+  backedUpBlobs,
+  globalBlobs,
+} from '../../../../storage/lib/mongodb.js'
+import cleanup from './support/cleanup.js'
+import testProjects from '../api/support/test_projects.js'
+import { execFile } from 'node:child_process'
+import { expect } from 'chai'
+import config from 'config'
+import ObjectPersistor from '@overleaf/object-persistor'
+import { WritableBuffer } from '@overleaf/stream-utils'
+import {
+  backupPersistor,
+  projectBlobsBucket,
+} from '../../../../storage/lib/backupPersistor.mjs'
+import projectKey from '../../../../storage/lib/project_key.js'
+import {
+  BlobStore,
+  makeProjectKey,
+} from '../../../../storage/lib/blob_store/index.js'
+
+const TIMEOUT = 20 * 1_000
+
+const { deksBucket } = config.get('backupStore')
+const { tieringStorageClass } = config.get('backupPersistor')
+
+const projectsCollection = db.collection('projects')
+const deletedProjectsCollection = db.collection('deletedProjects')
+const deletedFilesCollection = db.collection('deletedFiles')
+
+const FILESTORE_PERSISTOR = ObjectPersistor({
+  backend: 'gcs',
+  gcs: {
+    endpoint: {
+      apiEndpoint: process.env.GCS_API_ENDPOINT,
+      projectId: process.env.GCS_PROJECT_ID,
+    },
+  },
+})
+
+/**
+ * @param {ObjectId} objectId
+ * @return {string}
+ */
+function gitBlobHash(objectId) {
+  return gitBlobHashBuffer(Buffer.from(objectId.toString()))
+}
+
+/**
+ * @param {Buffer} buf
+ * @return {string}
+ */
+function gitBlobHashBuffer(buf) {
+  const sha = Crypto.createHash('sha1')
+  sha.update(`blob ${buf.byteLength}\x00`)
+  sha.update(buf)
+  return sha.digest('hex')
+}
+
+/**
+ * @param {string} gitBlobHash
+ * @return {Binary}
+ */
+function binaryForGitBlobHash(gitBlobHash) {
+  return new Binary(Buffer.from(gitBlobHash, 'hex'))
+}
+
+async function listS3Bucket(bucket, wantStorageClass) {
+  const client = backupPersistor._getClientForBucket(bucket)
+  const response = await client.listObjectsV2({ Bucket: bucket }).promise()
+
+  for (const object of response.Contents || []) {
+    expect(object).to.have.property('StorageClass', wantStorageClass)
+  }
+
+  return (response.Contents || []).map(item => item.Key || '')
+}
+
+function objectIdFromTime(timestamp) {
+  return ObjectId.createFromTime(new Date(timestamp).getTime() / 1000)
+}
+
+const PRINT_IDS_AND_HASHES_FOR_DEBUGGING = false
+
+describe('back_fill_file_hash script', function () {
+  this.timeout(TIMEOUT)
+  const USER_FILES_BUCKET_NAME = 'fake-user-files-gcs'
+
+  const projectId0 = objectIdFromTime('2017-01-01T00:00:00Z')
+  const projectId1 = objectIdFromTime('2017-01-01T00:01:00Z')
+  const projectId2 = objectIdFromTime('2017-01-01T00:02:00Z')
+  const projectId3 = objectIdFromTime('2024-01-01T00:03:00Z')
+  const projectIdDeleted0 = objectIdFromTime('2017-01-01T00:04:00Z')
+  const projectIdDeleted1 = objectIdFromTime('2024-01-01T00:05:00Z')
+  const projectIdNoHistory = objectIdFromTime('2017-01-01T00:06:00Z')
+  const projectIdNoHistoryDeleted = objectIdFromTime('2017-01-01T00:07:00Z')
+  const projectIdHardDeleted = objectIdFromTime('2017-01-01T00:08:00Z')
+  const projectIdNoOverleaf = objectIdFromTime('2017-01-01T00:09:00Z')
+  const projectIdNoOverleafDeleted = objectIdFromTime('2017-01-01T00:10:00Z')
+  const historyId0 = 42 // stored as number is mongo
+  const historyId1 = projectId1.toString()
+  const historyId2 = projectId2.toString()
+  const historyId3 = projectId3.toString()
+  const historyIdDeleted0 = projectIdDeleted0.toString()
+  const historyIdDeleted1 = projectIdDeleted1.toString()
+  const fileId0 = objectIdFromTime('2017-02-01T00:00:00Z')
+  const fileId1 = objectIdFromTime('2017-02-01T00:01:00Z')
+  const fileId2 = objectIdFromTime('2017-02-01T00:02:00Z')
+  const fileId3 = objectIdFromTime('2017-02-01T00:03:00Z')
+  const fileId4 = objectIdFromTime('2017-02-01T00:04:00Z')
+  const fileId5 = objectIdFromTime('2024-02-01T00:05:00Z')
+  const fileId6 = objectIdFromTime('2017-02-01T00:06:00Z')
+  const fileId7 = objectIdFromTime('2017-02-01T00:07:00Z')
+  const fileIdDeleted1 = objectIdFromTime('2017-02-01T00:07:00Z')
+  const fileIdDeleted2 = objectIdFromTime('2017-02-01T00:08:00Z')
+  const fileIdDeleted3 = objectIdFromTime('2017-02-01T00:09:00Z')
+  const fileIdDeleted4 = objectIdFromTime('2024-02-01T00:10:00Z')
+  const fileIdDeleted5 = objectIdFromTime('2024-02-01T00:11:00Z')
+  const deleteProjectsRecordId0 = new ObjectId()
+  const deleteProjectsRecordId1 = new ObjectId()
+  const deleteProjectsRecordId2 = new ObjectId()
+  const deleteProjectsRecordId3 = new ObjectId()
+  const deleteProjectsRecordId4 = new ObjectId()
+  const contentFile7 = Buffer.alloc(11_000_000)
+  const hashFile7 = gitBlobHashBuffer(contentFile7)
+  const writtenBlobs = [
+    { projectId: projectId0, historyId: historyId0, fileId: fileId0 },
+    // { historyId: projectId0, fileId: fileId6 }, // global blob
+    {
+      projectId: projectId0,
+      historyId: historyId0,
+      fileId: fileId7,
+      hash: hashFile7,
+    },
+    { projectId: projectId0, historyId: historyId0, fileId: fileIdDeleted5 },
+    { projectId: projectId1, historyId: historyId1, fileId: fileId1 },
+    { projectId: projectId1, historyId: historyId1, fileId: fileIdDeleted1 },
+    // { historyId: historyId2, fileId: fileId2 }, // already has hash
+    // { historyId: historyId3, fileId: fileId3 }, // too new
+    {
+      projectId: projectIdDeleted0,
+      historyId: historyIdDeleted0,
+      fileId: fileId4,
+    },
+    {
+      projectId: projectIdDeleted0,
+      historyId: historyIdDeleted0,
+      fileId: fileIdDeleted2,
+    },
+    // { historyId: historyIdDeleted0, fileId:fileIdDeleted3 }, // fileIdDeleted3 is dupe of fileIdDeleted2
+    // { historyId: historyIdDeleted0, fileId: fileIdDeleted4 }, // already has hash
+  ]
+  if (PRINT_IDS_AND_HASHES_FOR_DEBUGGING) {
+    const fileIds = {
+      fileId0,
+      fileId1,
+      fileId2,
+      fileId3,
+      fileId4,
+      fileId5,
+      fileId6,
+      fileIdDeleted1,
+      fileIdDeleted2,
+      fileIdDeleted3,
+      fileIdDeleted4,
+    }
+    console.log({
+      projectId0,
+      projectId1,
+      projectId2,
+      projectId3,
+      projectIdDeleted0,
+      projectIdDeleted1,
+      historyId0,
+      historyId1,
+      historyId2,
+      historyId3,
+      historyIdDeleted0,
+      historyIdDeleted1,
+      ...fileIds,
+    })
+    for (const [name, v] of Object.entries(fileIds)) {
+      console.log(name, gitBlobHash(v))
+    }
+  }
+
+  beforeEach(cleanup.everything)
+  beforeEach('cleanup s3 buckets', async function () {
+    await backupPersistor.deleteDirectory(deksBucket, '')
+    await backupPersistor.deleteDirectory(projectBlobsBucket, '')
+    expect(await listS3Bucket(deksBucket)).to.have.length(0)
+    expect(await listS3Bucket(projectBlobsBucket)).to.have.length(0)
+  })
+
+  beforeEach('populate mongo', async function () {
+    await globalBlobs.insertMany([
+      { _id: gitBlobHash(fileId6), byteLength: 24, stringLength: 24 },
+    ])
+    await projectsCollection.insertMany([
+      {
+        _id: projectId0,
+        rootFolder: [
+          {
+            fileRefs: [{ _id: fileId0 }, { _id: fileId6 }, { _id: fileId7 }],
+            folders: [{ fileRefs: [], folders: [] }],
+          },
+        ],
+        overleaf: { history: { id: historyId0 } },
+      },
+      {
+        _id: projectId1,
+        rootFolder: [
+          {
+            fileRefs: [{ _id: fileId1 }],
+            folders: [
+              {
+                fileRefs: [],
+                folders: [{ fileRefs: [{ _id: fileId1 }], folders: [] }],
+              },
+            ],
+          },
+        ],
+        overleaf: { history: { id: historyId1 } },
+      },
+      {
+        _id: projectId2,
+        rootFolder: [
+          {
+            fileRefs: [],
+            folders: [
+              {
+                fileRefs: [],
+                folders: [
+                  {
+                    fileRefs: [{ _id: fileId2, hash: gitBlobHash(fileId2) }],
+                    folders: [],
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+        overleaf: { history: { id: historyId2 } },
+      },
+      {
+        _id: projectId3,
+        rootFolder: [
+          {
+            fileRefs: [],
+            folders: [
+              {
+                fileRefs: [],
+                folders: [
+                  {
+                    fileRefs: [{ _id: fileId3, hash: gitBlobHash(fileId3) }],
+                    folders: [],
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+        overleaf: { history: { id: historyId3 } },
+      },
+      {
+        _id: projectIdNoHistory,
+        rootFolder: [{ fileRefs: [], folders: [] }],
+        overleaf: { history: { conversionFailed: true } },
+      },
+      {
+        _id: projectIdNoOverleaf,
+        rootFolder: [{ fileRefs: [], folders: [] }],
+      },
+    ])
+    await deletedProjectsCollection.insertMany([
+      {
+        _id: deleteProjectsRecordId0,
+        project: {
+          _id: projectIdDeleted0,
+          rootFolder: [
+            {
+              fileRefs: [],
+              folders: [
+                {
+                  fileRefs: [],
+                  folders: [{ fileRefs: [{ _id: fileId4 }], folders: [] }],
+                },
+              ],
+            },
+          ],
+          overleaf: { history: { id: historyIdDeleted0 } },
+        },
+        deleterData: {
+          deletedProjectId: projectIdDeleted0,
+        },
+      },
+      {
+        _id: deleteProjectsRecordId1,
+        project: {
+          _id: projectIdDeleted1,
+          rootFolder: [
+            {
+              fileRefs: [],
+              folders: [
+                {
+                  fileRefs: [],
+                  folders: [
+                    {
+                      fileRefs: [{ _id: fileId5, hash: gitBlobHash(fileId5) }],
+                      folders: [],
+                    },
+                  ],
+                },
+              ],
+            },
+          ],
+          overleaf: { history: { id: historyIdDeleted1 } },
+        },
+        deleterData: {
+          deletedProjectId: projectIdDeleted1,
+        },
+      },
+      {
+        _id: deleteProjectsRecordId2,
+        project: {
+          _id: projectIdNoHistoryDeleted,
+          rootFolder: [{ fileRefs: [], folders: [] }],
+          overleaf: { history: { conversionFailed: true } },
+        },
+        deleterData: {
+          deletedProjectId: projectIdNoHistoryDeleted,
+        },
+      },
+      {
+        _id: deleteProjectsRecordId3,
+        deleterData: { deletedProjectId: projectIdHardDeleted },
+      },
+      {
+        _id: deleteProjectsRecordId4,
+        project: {
+          _id: projectIdNoOverleafDeleted,
+          rootFolder: [{ fileRefs: [], folders: [] }],
+        },
+        deleterData: {
+          deletedProjectId: projectIdNoOverleafDeleted,
+        },
+      },
+    ])
+    await deletedFilesCollection.insertMany([
+      { _id: fileIdDeleted1, projectId: projectId1 },
+      { _id: fileIdDeleted2, projectId: projectIdDeleted0 },
+      { _id: fileIdDeleted3, projectId: projectIdDeleted0 },
+      {
+        _id: fileIdDeleted4,
+        projectId: projectIdDeleted0,
+        hash: gitBlobHash(fileIdDeleted4),
+      },
+      { _id: fileIdDeleted5, projectId: projectId0 },
+    ])
+
+    await testProjects.createEmptyProject(historyId0.toString())
+    await testProjects.createEmptyProject(historyId1)
+    await testProjects.createEmptyProject(historyId2)
+    await testProjects.createEmptyProject(historyId3)
+    await testProjects.createEmptyProject(historyIdDeleted0)
+    await testProjects.createEmptyProject(historyIdDeleted1)
+  })
+
+  beforeEach('populate filestore', async function () {
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectId0}/${fileId0}`,
+      Stream.Readable.from([fileId0.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectId0}/${fileId6}`,
+      Stream.Readable.from([fileId6.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectId0}/${fileId7}`,
+      Stream.Readable.from([contentFile7])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectId0}/${fileIdDeleted5}`,
+      Stream.Readable.from([fileIdDeleted5.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectId1}/${fileId1}`,
+      Stream.Readable.from([fileId1.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectId2}/${fileId2}`,
+      Stream.Readable.from([fileId2.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectId3}/${fileId3}`,
+      Stream.Readable.from([fileId3.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectIdDeleted0}/${fileId4}`,
+      Stream.Readable.from([fileId4.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectIdDeleted1}/${fileId5}`,
+      Stream.Readable.from([fileId5.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectId1}/${fileIdDeleted1}`,
+      Stream.Readable.from([fileIdDeleted1.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectIdDeleted0}/${fileIdDeleted2}`,
+      Stream.Readable.from([fileIdDeleted2.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectIdDeleted0}/${fileIdDeleted3}`,
+      // same content as 2, deduplicate
+      Stream.Readable.from([fileIdDeleted2.toString()])
+    )
+    await FILESTORE_PERSISTOR.sendStream(
+      USER_FILES_BUCKET_NAME,
+      `${projectIdDeleted0}/${fileIdDeleted4}`,
+      Stream.Readable.from([fileIdDeleted4.toString()])
+    )
+  })
+
+  async function tryRunScript(env = {}) {
+    let result
+    try {
+      result = await promisify(execFile)(
+        process.argv0,
+        [
+          'storage/scripts/back_fill_file_hash.mjs',
+          'live',
+          'deleted',
+          'deletedFiles',
+        ],
+        {
+          encoding: 'utf-8',
+          timeout: TIMEOUT - 500,
+          env: {
+            ...process.env,
+            USER_FILES_BUCKET_NAME,
+            ...env,
+            LOG_LEVEL: 'warn', // Override LOG_LEVEL of acceptance tests
+          },
+        }
+      )
+      result.status = 0
+    } catch (err) {
+      const { stdout, stderr, code } = err
+      if (typeof code !== 'number') {
+        console.log(err)
+      }
+      result = { stdout, stderr, status: code }
+    }
+    const stats = JSON.parse(result.stdout.trimEnd().split('\n').pop())
+    expect(new Date(stats.time).toISOString()).to.equal(stats.time)
+    delete stats.time
+    return { stats, result }
+  }
+
+  async function runScript(env = {}) {
+    const { stats, result } = await tryRunScript(env)
+    if (result.status !== 0) {
+      console.log(result)
+      expect(result).to.have.property('status', 0)
+    }
+    return { stats, result }
+  }
+
+  function commonAssertions() {
+    it('should update mongo', async function () {
+      expect(await projectsCollection.find({}).toArray()).to.deep.equal([
+        {
+          _id: projectId0,
+          rootFolder: [
+            {
+              fileRefs: [
+                { _id: fileId0, hash: gitBlobHash(fileId0) },
+                { _id: fileId6, hash: gitBlobHash(fileId6) },
+                { _id: fileId7, hash: hashFile7 },
+              ],
+              folders: [{ fileRefs: [], folders: [] }],
+            },
+          ],
+          overleaf: { history: { id: historyId0 } },
+        },
+        {
+          _id: projectId1,
+          rootFolder: [
+            {
+              fileRefs: [{ _id: fileId1, hash: gitBlobHash(fileId1) }],
+              folders: [
+                {
+                  fileRefs: [],
+                  folders: [
+                    {
+                      fileRefs: [{ _id: fileId1, hash: gitBlobHash(fileId1) }],
+                      folders: [],
+                    },
+                  ],
+                },
+              ],
+            },
+          ],
+          overleaf: { history: { id: historyId1 } },
+        },
+        {
+          _id: projectId2,
+          rootFolder: [
+            {
+              fileRefs: [],
+              folders: [
+                {
+                  fileRefs: [],
+                  folders: [
+                    {
+                      fileRefs: [{ _id: fileId2, hash: gitBlobHash(fileId2) }],
+                      folders: [],
+                    },
+                  ],
+                },
+              ],
+            },
+          ],
+          overleaf: { history: { id: historyId2 } },
+        },
+        {
+          _id: projectId3,
+          rootFolder: [
+            {
+              fileRefs: [],
+              folders: [
+                {
+                  fileRefs: [],
+                  folders: [
+                    {
+                      fileRefs: [{ _id: fileId3, hash: gitBlobHash(fileId3) }],
+                      folders: [],
+                    },
+                  ],
+                },
+              ],
+            },
+          ],
+          overleaf: { history: { id: historyId3 } },
+        },
+        {
+          _id: projectIdNoHistory,
+          rootFolder: [{ fileRefs: [], folders: [] }],
+          overleaf: { history: { conversionFailed: true } },
+        },
+        {
+          _id: projectIdNoOverleaf,
+          rootFolder: [{ fileRefs: [], folders: [] }],
+        },
+      ])
+      expect(await deletedProjectsCollection.find({}).toArray()).to.deep.equal([
+        {
+          _id: deleteProjectsRecordId0,
+          project: {
+            _id: projectIdDeleted0,
+            rootFolder: [
+              {
+                fileRefs: [],
+                folders: [
+                  {
+                    fileRefs: [],
+                    folders: [
+                      {
+                        fileRefs: [
+                          { _id: fileId4, hash: gitBlobHash(fileId4) },
+                        ],
+                        folders: [],
+                      },
+                    ],
+                  },
+                ],
+              },
+            ],
+            overleaf: { history: { id: historyIdDeleted0 } },
+          },
+          deleterData: {
+            deletedProjectId: projectIdDeleted0,
+          },
+        },
+        {
+          _id: deleteProjectsRecordId1,
+          project: {
+            _id: projectIdDeleted1,
+            rootFolder: [
+              {
+                fileRefs: [],
+                folders: [
+                  {
+                    fileRefs: [],
+                    folders: [
+                      {
+                        fileRefs: [
+                          { _id: fileId5, hash: gitBlobHash(fileId5) },
+                        ],
+                        folders: [],
+                      },
+                    ],
+                  },
+                ],
+              },
+            ],
+            overleaf: { history: { id: historyIdDeleted1 } },
+          },
+          deleterData: {
+            deletedProjectId: projectIdDeleted1,
+          },
+        },
+        {
+          _id: deleteProjectsRecordId2,
+          project: {
+            _id: projectIdNoHistoryDeleted,
+            rootFolder: [{ fileRefs: [], folders: [] }],
+            overleaf: { history: { conversionFailed: true } },
+          },
+          deleterData: {
+            deletedProjectId: projectIdNoHistoryDeleted,
+          },
+        },
+        {
+          _id: deleteProjectsRecordId3,
+          deleterData: { deletedProjectId: projectIdHardDeleted },
+        },
+        {
+          _id: deleteProjectsRecordId4,
+          project: {
+            _id: projectIdNoOverleafDeleted,
+            rootFolder: [{ fileRefs: [], folders: [] }],
+          },
+          deleterData: {
+            deletedProjectId: projectIdNoOverleafDeleted,
+          },
+        },
+      ])
+      expect(await deletedFilesCollection.find({}).toArray()).to.deep.equal([
+        {
+          _id: fileIdDeleted1,
+          projectId: projectId1,
+          hash: gitBlobHash(fileIdDeleted1),
+        },
+        {
+          _id: fileIdDeleted2,
+          projectId: projectIdDeleted0,
+          hash: gitBlobHash(fileIdDeleted2),
+        },
+        {
+          _id: fileIdDeleted3,
+          projectId: projectIdDeleted0,
+          // uses the same content as fileIdDeleted2
+          hash: gitBlobHash(fileIdDeleted2),
+        },
+        {
+          _id: fileIdDeleted4,
+          projectId: projectIdDeleted0,
+          hash: gitBlobHash(fileIdDeleted4),
+        },
+        {
+          _id: fileIdDeleted5,
+          projectId: projectId0,
+          hash: gitBlobHash(fileIdDeleted5),
+        },
+      ])
+      expect(
+        (await backedUpBlobs.find({}, { sort: { _id: 1 } }).toArray()).map(
+          entry => {
+            // blobs are pushed unordered into mongo. Sort the list for consistency.
+            entry.blobs.sort()
+            return entry
+          }
+        )
+      ).to.deep.equal([
+        {
+          _id: projectId0,
+          blobs: [
+            binaryForGitBlobHash(gitBlobHash(fileId0)),
+            binaryForGitBlobHash(hashFile7),
+            binaryForGitBlobHash(gitBlobHash(fileIdDeleted5)),
+          ].sort(),
+        },
+        {
+          _id: projectId1,
+          blobs: [
+            binaryForGitBlobHash(gitBlobHash(fileId1)),
+            binaryForGitBlobHash(gitBlobHash(fileIdDeleted1)),
+          ].sort(),
+        },
+        {
+          _id: projectIdDeleted0,
+          blobs: [
+            binaryForGitBlobHash(gitBlobHash(fileId4)),
+            binaryForGitBlobHash(gitBlobHash(fileIdDeleted2)),
+          ].sort(),
+        },
+      ])
+    })
+    it('should process nothing on re-run', async function () {
+      const rerun = await runScript()
+      expect(rerun.stats).deep.equal({
+        ...STATS_ALL_ZERO,
+        // We still need to iterate over all the projects.
+        projects: 4,
+      })
+    })
+    it('should have backed up all the files', async function () {
+      expect(tieringStorageClass).to.exist
+      const blobs = await listS3Bucket(projectBlobsBucket, tieringStorageClass)
+      expect(blobs.sort()).to.deep.equal(
+        writtenBlobs
+          .map(({ historyId, fileId, hash }) =>
+            makeProjectKey(historyId, hash || gitBlobHash(fileId))
+          )
+          .sort()
+      )
+      for (let { historyId, fileId, hash } of writtenBlobs) {
+        hash = hash || gitBlobHash(fileId.toString())
+        const s = await backupPersistor.getObjectStream(
+          projectBlobsBucket,
+          makeProjectKey(historyId, hash),
+          { autoGunzip: true }
+        )
+        const buf = new WritableBuffer()
+        await Stream.promises.pipeline(s, buf)
+        expect(gitBlobHashBuffer(buf.getContents())).to.equal(hash)
+        if (fileId !== fileId7) {
+          const id = buf.getContents().toString('utf-8')
+          expect(id).to.equal(fileId.toString())
+          // double check we are not comparing 'undefined' or '[object Object]' above
+          expect(id).to.match(/^[a-f0-9]{24}$/)
+        }
+      }
+      const deks = await listS3Bucket(deksBucket, 'STANDARD')
+      expect(deks.sort()).to.deep.equal(
+        Array.from(
+          new Set(
+            writtenBlobs.map(
+              ({ historyId }) => projectKey.format(historyId) + '/dek'
+            )
+          )
+        ).sort()
+      )
+    })
+    it('should have written the back filled files to history v1', async function () {
+      for (const { historyId, fileId } of writtenBlobs) {
+        const blobStore = new BlobStore(historyId.toString())
+        if (fileId === fileId7) {
+          const s = await blobStore.getStream(hashFile7)
+          const buf = new WritableBuffer()
+          await Stream.promises.pipeline(s, buf)
+          expect(buf.getContents()).to.deep.equal(contentFile7)
+          continue
+        }
+        const id = await blobStore.getString(gitBlobHash(fileId.toString()))
+        expect(id).to.equal(fileId.toString())
+        // double check we are not comparing 'undefined' or '[object Object]' above
+        expect(id).to.match(/^[a-f0-9]{24}$/)
+      }
+    })
+  }
+
+  function expectNotFoundError(result, msg) {
+    expect(result.stdout).to.include(msg)
+    const log = JSON.parse(
+      result.stdout.split('\n').find(l => l.includes(`"${msg}"`))
+    )
+    expect(log).to.contain({
+      projectId: projectId0.toString(),
+      fileId: fileId0.toString(),
+      path: 'rootFolder.0.fileRefs.0',
+      msg,
+    })
+    expect(log.err).to.contain({
+      name: 'NotFoundError',
+    })
+  }
+
+  const STATS_ALL_ZERO = {
+    projects: 0,
+    filesWithoutHash: 0,
+    filesDuplicated: 0,
+    filesRetries: 0,
+    filesFailed: 0,
+    globalBlobsCount: 0,
+    globalBlobsEgress: 0,
+    fileTreeUpdated: 0,
+    projectDeleted: 0,
+    projectHardDeleted: 0,
+    fileHardDeleted: 0,
+    mongoUpdates: 0,
+    deduplicatedWriteToAWSLocalCount: 0,
+    deduplicatedWriteToAWSLocalEgress: 0,
+    deduplicatedWriteToAWSRemoteCount: 0,
+    deduplicatedWriteToAWSRemoteEgress: 0,
+    writeToAWSCount: 0,
+    writeToAWSEgress: 0,
+  }
+  const STATS_UP_TO_PROJECT1 = {
+    projects: 2,
+    filesWithoutHash: 7,
+    filesDuplicated: 1,
+    filesRetries: 0,
+    filesFailed: 0,
+    globalBlobsCount: 1,
+    globalBlobsEgress: 30,
+    fileTreeUpdated: 0,
+    projectDeleted: 0,
+    projectHardDeleted: 0,
+    fileHardDeleted: 0,
+    mongoUpdates: 6,
+    deduplicatedWriteToAWSLocalCount: 0,
+    deduplicatedWriteToAWSLocalEgress: 0,
+    deduplicatedWriteToAWSRemoteCount: 0,
+    deduplicatedWriteToAWSRemoteEgress: 0,
+    writeToAWSCount: 5,
+    writeToAWSEgress: 11000118,
+  }
+  const STATS_UP_FROM_PROJECT1_ONWARD = {
+    projects: 2,
+    filesWithoutHash: 3,
+    filesDuplicated: 0,
+    filesRetries: 0,
+    filesFailed: 0,
+    globalBlobsCount: 0,
+    globalBlobsEgress: 0,
+    fileTreeUpdated: 0,
+    projectDeleted: 0,
+    projectHardDeleted: 0,
+    fileHardDeleted: 0,
+    mongoUpdates: 4,
+    deduplicatedWriteToAWSLocalCount: 1,
+    deduplicatedWriteToAWSLocalEgress: 30,
+    deduplicatedWriteToAWSRemoteCount: 0,
+    deduplicatedWriteToAWSRemoteEgress: 0,
+    writeToAWSCount: 2,
+    writeToAWSEgress: 58,
+  }
+
+  function sumStats(a, b) {
+    return Object.fromEntries(Object.entries(a).map(([k, v]) => [k, v + b[k]]))
+  }
+
+  const STATS_ALL = sumStats(
+    STATS_UP_TO_PROJECT1,
+    STATS_UP_FROM_PROJECT1_ONWARD
+  )
+
+  it('should gracefully handle fatal errors', async function () {
+    await FILESTORE_PERSISTOR.deleteObject(
+      USER_FILES_BUCKET_NAME,
+      `${projectId0}/${fileId0}`
+    )
+    const t0 = Date.now()
+    const { stats, result } = await tryRunScript({
+      RETRIES: '10',
+      RETRY_DELAY_MS: '1000',
+    })
+    const t1 = Date.now()
+    expectNotFoundError(result, 'failed to process file')
+    expect(result.status).to.equal(1)
+    expect(stats).to.deep.equal(
+      sumStats(STATS_ALL, {
+        ...STATS_ALL_ZERO,
+        filesFailed: 1,
+        writeToAWSCount: -1,
+        writeToAWSEgress: -28,
+      })
+    )
+    // should not retry 404
+    expect(result.stdout).to.not.include('failed to process file, trying again')
+    expect(t1 - t0).to.be.below(10_000)
+  })
+
+  it('should retry on error', async function () {
+    await FILESTORE_PERSISTOR.deleteObject(
+      USER_FILES_BUCKET_NAME,
+      `${projectId0}/${fileId0}`
+    )
+    const restoreFileAfter5s = async () => {
+      await setTimeout(5_000)
+      await FILESTORE_PERSISTOR.sendStream(
+        USER_FILES_BUCKET_NAME,
+        `${projectId0}/${fileId0}`,
+        Stream.Readable.from([fileId0.toString()])
+      )
+    }
+    // use Promise.allSettled to ensure the above sendStream call finishes before this test completes
+    const [
+      {
+        value: { stats, result },
+      },
+    ] = await Promise.allSettled([
+      tryRunScript({
+        RETRY_DELAY_MS: '100',
+        RETRIES: '60',
+        RETRY_FILESTORE_404: 'true', // 404s are the easiest to simulate in tests
+      }),
+      restoreFileAfter5s(),
+    ])
+    expectNotFoundError(result, 'failed to process file, trying again')
+    expect(result.status).to.equal(0)
+    expect({ ...stats, filesRetries: 0 }).to.deep.equal(STATS_ALL)
+    expect(stats.filesRetries).to.be.greaterThan(0)
+  })
+
+  describe('full run CONCURRENCY=1', function () {
+    let output
+    beforeEach('run script', async function () {
+      output = await runScript({
+        CONCURRENCY: '1',
+      })
+    })
+
+    it('should print stats', function () {
+      expect(output.stats).deep.equal(STATS_ALL)
+    })
+    commonAssertions()
+  })
+
+  describe('full run CONCURRENCY=10', function () {
+    let output
+    beforeEach('run script', async function () {
+      output = await runScript({
+        CONCURRENCY: '10',
+      })
+    })
+    it('should print stats', function () {
+      expect(output.stats).deep.equal(STATS_ALL)
+    })
+    commonAssertions()
+  })
+
+  describe('with something in the bucket already', function () {
+    beforeEach('create a file in s3', async function () {
+      const buf = Buffer.from(fileId0.toString())
+      await backupPersistor.sendStream(
+        projectBlobsBucket,
+        makeProjectKey(historyId0, gitBlobHash(fileId0)),
+        Stream.Readable.from([buf]),
+        { contentLength: buf.byteLength }
+      )
+    })
+    let output
+    beforeEach('run script', async function () {
+      output = await runScript({
+        CONCURRENCY: '1',
+      })
+    })
+
+    it('should print stats', function () {
+      expect(output.stats).deep.equal(
+        sumStats(STATS_ALL, {
+          ...STATS_ALL_ZERO,
+          // one remote deduplicate
+          deduplicatedWriteToAWSRemoteCount: 1,
+          deduplicatedWriteToAWSRemoteEgress: 28,
+          writeToAWSEgress: -28, // subtract skipped egress
+        })
+      )
+    })
+    commonAssertions()
+  })
+
+  describe('split run CONCURRENCY=1', function () {
+    // part0: project0+project1, part1: project2 onwards
+    const edge = projectId1.toString()
+    let outputPart0, outputPart1
+    beforeEach('run script on part 0', async function () {
+      outputPart0 = await runScript({
+        CONCURRENCY: '1',
+        BATCH_RANGE_END: edge,
+      })
+    })
+    beforeEach('run script on part 1', async function () {
+      outputPart1 = await runScript({
+        CONCURRENCY: '1',
+        BATCH_RANGE_START: edge,
+      })
+    })
+
+    it('should print stats', function () {
+      expect(outputPart0.stats).to.deep.equal(STATS_UP_TO_PROJECT1)
+      expect(outputPart1.stats).to.deep.equal(STATS_UP_FROM_PROJECT1_ONWARD)
+    })
+    commonAssertions()
+  })
+})

+ 6 - 0
services/history-v1/test/acceptance/js/storage/support/cleanup.js

@@ -14,6 +14,12 @@ const MONGO_COLLECTIONS = [
   'projectHistoryBlobs',
   'projectHistoryShardedBlobs',
   'projectHistoryChunks',
+
+  // back_fill_file_hash.test.mjs
+  'deletedFiles',
+  'deletedProjects',
+  'projects',
+  'projectHistoryBackedUpBlobs',
 ]
 
 // make sure we don't delete the wrong data by accident

+ 1 - 0
services/history-v1/test/setup.js

@@ -35,6 +35,7 @@ async function createGcsBuckets() {
     config.get('blobStore.projectBucket'),
     config.get('chunkStore.bucket'),
     config.get('zipStore.bucket'),
+    'fake-user-files-gcs',
   ]) {
     await fetch('http://gcs:9090/storage/v1/b', {
       method: 'POST',